diff --git a/app/app_iface.go b/app/app_iface.go index ef6b3b4733..aed132ba5f 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -871,6 +871,7 @@ type AppIface interface { InviteNewUsersToTeam(emailList []string, teamID, senderId string) *model.AppError InviteNewUsersToTeamGracefully(memberInvite *model.MemberInvite, teamID, senderId string, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError) IsCRTEnabledForUser(c request.CTX, userID string) bool + IsFirstAdmin(user *model.User) bool IsFirstUserAccount() bool IsLeader() bool IsPasswordValid(password string) *model.AppError diff --git a/app/channel.go b/app/channel.go index 8da9b638f9..504c7265d6 100644 --- a/app/channel.go +++ b/app/channel.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/mattermost/logr/v2" "github.com/mattermost/mattermost-server/v6/app/request" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" @@ -130,6 +131,35 @@ func (a *App) JoinDefaultChannels(c request.CTX, teamID string, user *model.User message.Add("user_id", user.Id) message.Add("team_id", channel.TeamId) a.Publish(message) + + // A/B Test on the welcome post + if a.Config().FeatureFlags.SendWelcomePost && channelName == model.DefaultChannelName { + nbTeams, err := a.Srv().Store().Team().AnalyticsTeamCount(&model.TeamSearch{ + IncludeDeleted: model.NewBool(true), + }) + if err != nil { + c.Logger().Warn("unable to get number of teams", logr.Err(err)) + return nil + } + + if nbTeams == 1 && a.IsFirstAdmin(user) { + // Post the welcome message + if _, err := a.CreatePost(c, &model.Post{ + ChannelId: channel.Id, + Type: model.PostTypeWelcomePost, + UserId: user.Id, + }, channel, false, false); err != nil { + c.Logger().Warn("unable to post welcome message", logr.Err(err)) + return nil + } + ts := a.Srv().GetTelemetryService() + if ts != nil { + ts.SendTelemetry("welcome-message-sent", map[string]any{ + "category": "growth", + }) + } + } + } } if nErr != nil { diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 6dd849aefb..97f929c77d 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -11626,6 +11626,23 @@ func (a *OpenTracingAppLayer) IsCRTEnabledForUser(c request.CTX, userID string) return resultVar0 } +func (a *OpenTracingAppLayer) IsFirstAdmin(user *model.User) bool { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IsFirstAdmin") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.IsFirstAdmin(user) + + return resultVar0 +} + func (a *OpenTracingAppLayer) IsFirstUserAccount() bool { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IsFirstUserAccount") diff --git a/app/user.go b/app/user.go index bdb9fc8841..c0a9773e61 100644 --- a/app/user.go +++ b/app/user.go @@ -209,6 +209,19 @@ func (a *App) IsFirstUserAccount() bool { return a.ch.srv.platform.IsFirstUserAccount() } +func (a *App) IsFirstAdmin(user *model.User) bool { + if !user.IsSystemAdmin() { + return false + } + + adminID, err := a.Srv().Store().User().GetFirstSystemAdminID() + if err != nil { + return false + } + + return adminID == user.Id +} + // CreateUser creates a user and sets several fields of the returned User struct to // their zero values. func (a *App) CreateUser(c request.CTX, user *model.User) (*model.User, *model.AppError) { diff --git a/app/user_test.go b/app/user_test.go index 174c193deb..139cc1c1e5 100644 --- a/app/user_test.go +++ b/app/user_test.go @@ -1801,3 +1801,54 @@ func TestCreateUserWithInitialPreferences(t *testing.T) { assert.Equal(t, "false", recommendedNextStepsPref[0].Value) }) } + +func TestIsFirstAdmin(t *testing.T) { + t.Run("should return false if user is not sysadmin", func(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + Id := model.NewId() + isFirstAdmin := th.App.IsFirstAdmin(&model.User{ + Id: Id, + Roles: model.SystemUserRoleId, + }) + require.False(t, isFirstAdmin) + }) + + t.Run("should return false if user is sysadmin but not the first one", func(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + Id := model.NewId() + + mockUserStore := storemocks.UserStore{} + mockUserStore.On("GetFirstSystemAdminID").Return(model.NewId(), nil) + + mockStore := th.App.Srv().Store().(*storemocks.Store) + mockStore.On("User").Return(&mockUserStore) + + isFirstAdmin := th.App.IsFirstAdmin(&model.User{ + Id: Id, + Roles: model.SystemAdminRoleId, + }) + require.False(t, isFirstAdmin) + }) + + t.Run("should return true if user is sysadmin and the first one", func(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + Id := model.NewId() + + mockStore := th.App.Srv().Store().(*storemocks.Store) + mockUserStore := storemocks.UserStore{} + mockUserStore.On("GetFirstSystemAdminID").Return(Id, nil) + mockStore.On("User").Return(&mockUserStore) + + isFirstAdmin := th.App.IsFirstAdmin(&model.User{ + Id: Id, + Roles: model.SystemAdminRoleId, + }) + require.True(t, isFirstAdmin) + }) +} diff --git a/model/feature_flags.go b/model/feature_flags.go index 04b2eab18d..9a111d216b 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -72,6 +72,9 @@ type FeatureFlags struct { PlanUpgradeButtonText string + // A/B Test on posting a welcome message + SendWelcomePost bool + PostPriority bool } @@ -99,6 +102,7 @@ func (f *FeatureFlags) SetDefaults() { f.CallsEnabled = true f.BoardsProduct = false f.PlanUpgradeButtonText = "upgrade" + f.SendWelcomePost = true f.PostPriority = false } diff --git a/model/post.go b/model/post.go index 4916779b52..3f9c5a630c 100644 --- a/model/post.go +++ b/model/post.go @@ -44,6 +44,7 @@ const ( PostTypeChannelRestored = "system_channel_restored" PostTypeEphemeral = "system_ephemeral" PostTypeChangeChannelPrivacy = "system_change_chan_privacy" + PostTypeWelcomePost = "system_welcome_post" PostTypeAddBotTeamsChannels = "add_bot_teams_channels" PostTypeSystemWarnMetricStatus = "warn_metric_status" PostTypeMe = "me" @@ -387,6 +388,7 @@ func (o *Post) IsValid(maxPostSize int) *AppError { PostTypeChangeChannelPrivacy, PostTypeAddBotTeamsChannels, PostTypeSystemWarnMetricStatus, + PostTypeWelcomePost, PostTypeMe: default: if !strings.HasPrefix(o.Type, PostCustomTypePrefix) { diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index eb65b1f798..44f7d09e86 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -10801,6 +10801,24 @@ func (s *OpenTracingLayerUserStore) GetEtagForProfilesNotInTeam(teamID string) s return result } +func (s *OpenTracingLayerUserStore) GetFirstSystemAdminID() (string, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.GetFirstSystemAdminID") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.UserStore.GetFirstSystemAdminID() + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerUserStore) GetForLogin(loginID string, allowSignInWithUsername bool, allowSignInWithEmail bool) (*model.User, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.GetForLogin") diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index 4efa7172ba..292924476a 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -12324,6 +12324,27 @@ func (s *RetryLayerUserStore) GetEtagForProfilesNotInTeam(teamID string) string } +func (s *RetryLayerUserStore) GetFirstSystemAdminID() (string, error) { + + tries := 0 + for { + result, err := s.UserStore.GetFirstSystemAdminID() + 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 *RetryLayerUserStore) GetForLogin(loginID string, allowSignInWithUsername bool, allowSignInWithEmail bool) (*model.User, error) { tries := 0 diff --git a/store/sqlstore/user_store.go b/store/sqlstore/user_store.go index 0f724405d2..bd89779355 100644 --- a/store/sqlstore/user_store.go +++ b/store/sqlstore/user_store.go @@ -1748,6 +1748,16 @@ func (us SqlUserStore) InferSystemInstallDate() (int64, error) { return createAt, nil } +func (us SqlUserStore) GetFirstSystemAdminID() (string, error) { + var id string + err := us.GetReplicaX().Get(&id, "SELECT Id FROM Users WHERE Roles LIKE ? ORDER BY CreateAt ASC LIMIT 1", "%system_admin%") + if err != nil { + return "", errors.Wrap(err, "failed to get first system admin") + } + + return id, nil +} + func (us SqlUserStore) GetUsersBatchForIndexing(startTime int64, startFileID string, limit int) ([]*model.UserForIndexing, error) { users := []*model.User{} usersQuery, args, err := us.usersQuery. diff --git a/store/store.go b/store/store.go index b917adbede..f7c33a938e 100644 --- a/store/store.go +++ b/store/store.go @@ -481,6 +481,7 @@ type UserStore interface { IsEmpty(excludeBots bool) (bool, error) GetUsersWithInvalidEmails(page int, perPage int, restrictedDomains string) ([]*model.User, error) InsertUsers(users []*model.User) error + GetFirstSystemAdminID() (string, error) } type BotStore interface { diff --git a/store/storetest/mocks/UserStore.go b/store/storetest/mocks/UserStore.go index ea0fdcfcc0..290b37dae7 100644 --- a/store/storetest/mocks/UserStore.go +++ b/store/storetest/mocks/UserStore.go @@ -569,6 +569,27 @@ func (_m *UserStore) GetEtagForProfilesNotInTeam(teamID string) string { return r0 } +// GetFirstSystemAdminID provides a mock function with given fields: +func (_m *UserStore) GetFirstSystemAdminID() (string, error) { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetForLogin provides a mock function with given fields: loginID, allowSignInWithUsername, allowSignInWithEmail func (_m *UserStore) GetForLogin(loginID string, allowSignInWithUsername bool, allowSignInWithEmail bool) (*model.User, error) { ret := _m.Called(loginID, allowSignInWithUsername, allowSignInWithEmail) diff --git a/store/storetest/user_store.go b/store/storetest/user_store.go index 98d6f2bc4f..6d704854b4 100644 --- a/store/storetest/user_store.go +++ b/store/storetest/user_store.go @@ -94,6 +94,7 @@ func TestUserStore(t *testing.T, ss store.Store, s SqlStore) { t.Run("ResetLastPictureUpdate", func(t *testing.T) { testUserStoreResetLastPictureUpdate(t, ss) }) t.Run("GetKnownUsers", func(t *testing.T) { testGetKnownUsers(t, ss) }) t.Run("GetUsersWithInvalidEmails", func(t *testing.T) { testGetUsersWithInvalidEmails(t, ss) }) + t.Run("GetFirstSystemAdminID", func(t *testing.T) { testUserStoreGetFirstSystemAdminID(t, ss) }) } func testUserStoreSave(t *testing.T, ss store.Store) { @@ -4163,6 +4164,30 @@ func testCount(t *testing.T, ss store.Store) { } } +func testUserStoreGetFirstSystemAdminID(t *testing.T, ss store.Store) { + sysAdmin := &model.User{} + sysAdmin.Email = MakeEmail() + sysAdmin.Roles = model.SystemAdminRoleId + " " + model.SystemUserRoleId + sysAdmin, err := ss.User().Save(sysAdmin) + require.NoError(t, err) + defer func() { require.NoError(t, ss.User().PermanentDelete(sysAdmin.Id)) }() + + // We need the second system admin to be created after the first one + // our granulirity is ms + time.Sleep(1 * time.Millisecond) + + sysAdmin2 := &model.User{} + sysAdmin2.Email = MakeEmail() + sysAdmin2.Roles = model.SystemAdminRoleId + " " + model.SystemUserRoleId + sysAdmin2, err = ss.User().Save(sysAdmin2) + require.NoError(t, err) + defer func() { require.NoError(t, ss.User().PermanentDelete(sysAdmin2.Id)) }() + + returnedId, err := ss.User().GetFirstSystemAdminID() + require.NoError(t, err) + require.Equal(t, sysAdmin.Id, returnedId) +} + func testUserStoreAnalyticsActiveCount(t *testing.T, ss store.Store, s SqlStore) { cleanupStatusStore(t, s) diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index 0d7b338767..0558fa66c1 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -9727,6 +9727,22 @@ func (s *TimerLayerUserStore) GetEtagForProfilesNotInTeam(teamID string) string return result } +func (s *TimerLayerUserStore) GetFirstSystemAdminID() (string, error) { + start := time.Now() + + result, err := s.UserStore.GetFirstSystemAdminID() + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("UserStore.GetFirstSystemAdminID", success, elapsed) + } + return result, err +} + func (s *TimerLayerUserStore) GetForLogin(loginID string, allowSignInWithUsername bool, allowSignInWithEmail bool) (*model.User, error) { start := time.Now()