[MM-46694] A/B Test: welcome post (#20926)
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Этот коммит содержится в:
@@ -871,6 +871,7 @@ type AppIface interface {
|
|||||||
InviteNewUsersToTeam(emailList []string, teamID, senderId string) *model.AppError
|
InviteNewUsersToTeam(emailList []string, teamID, senderId string) *model.AppError
|
||||||
InviteNewUsersToTeamGracefully(memberInvite *model.MemberInvite, teamID, senderId string, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError)
|
InviteNewUsersToTeamGracefully(memberInvite *model.MemberInvite, teamID, senderId string, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError)
|
||||||
IsCRTEnabledForUser(c request.CTX, userID string) bool
|
IsCRTEnabledForUser(c request.CTX, userID string) bool
|
||||||
|
IsFirstAdmin(user *model.User) bool
|
||||||
IsFirstUserAccount() bool
|
IsFirstUserAccount() bool
|
||||||
IsLeader() bool
|
IsLeader() bool
|
||||||
IsPasswordValid(password string) *model.AppError
|
IsPasswordValid(password string) *model.AppError
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mattermost/logr/v2"
|
||||||
"github.com/mattermost/mattermost-server/v6/app/request"
|
"github.com/mattermost/mattermost-server/v6/app/request"
|
||||||
"github.com/mattermost/mattermost-server/v6/model"
|
"github.com/mattermost/mattermost-server/v6/model"
|
||||||
"github.com/mattermost/mattermost-server/v6/plugin"
|
"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("user_id", user.Id)
|
||||||
message.Add("team_id", channel.TeamId)
|
message.Add("team_id", channel.TeamId)
|
||||||
a.Publish(message)
|
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 {
|
if nErr != nil {
|
||||||
|
|||||||
@@ -11626,6 +11626,23 @@ func (a *OpenTracingAppLayer) IsCRTEnabledForUser(c request.CTX, userID string)
|
|||||||
return resultVar0
|
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 {
|
func (a *OpenTracingAppLayer) IsFirstUserAccount() bool {
|
||||||
origCtx := a.ctx
|
origCtx := a.ctx
|
||||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IsFirstUserAccount")
|
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IsFirstUserAccount")
|
||||||
|
|||||||
13
app/user.go
13
app/user.go
@@ -209,6 +209,19 @@ func (a *App) IsFirstUserAccount() bool {
|
|||||||
return a.ch.srv.platform.IsFirstUserAccount()
|
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
|
// CreateUser creates a user and sets several fields of the returned User struct to
|
||||||
// their zero values.
|
// their zero values.
|
||||||
func (a *App) CreateUser(c request.CTX, user *model.User) (*model.User, *model.AppError) {
|
func (a *App) CreateUser(c request.CTX, user *model.User) (*model.User, *model.AppError) {
|
||||||
|
|||||||
@@ -1801,3 +1801,54 @@ func TestCreateUserWithInitialPreferences(t *testing.T) {
|
|||||||
assert.Equal(t, "false", recommendedNextStepsPref[0].Value)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ type FeatureFlags struct {
|
|||||||
|
|
||||||
PlanUpgradeButtonText string
|
PlanUpgradeButtonText string
|
||||||
|
|
||||||
|
// A/B Test on posting a welcome message
|
||||||
|
SendWelcomePost bool
|
||||||
|
|
||||||
PostPriority bool
|
PostPriority bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +102,7 @@ func (f *FeatureFlags) SetDefaults() {
|
|||||||
f.CallsEnabled = true
|
f.CallsEnabled = true
|
||||||
f.BoardsProduct = false
|
f.BoardsProduct = false
|
||||||
f.PlanUpgradeButtonText = "upgrade"
|
f.PlanUpgradeButtonText = "upgrade"
|
||||||
|
f.SendWelcomePost = true
|
||||||
f.PostPriority = false
|
f.PostPriority = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const (
|
|||||||
PostTypeChannelRestored = "system_channel_restored"
|
PostTypeChannelRestored = "system_channel_restored"
|
||||||
PostTypeEphemeral = "system_ephemeral"
|
PostTypeEphemeral = "system_ephemeral"
|
||||||
PostTypeChangeChannelPrivacy = "system_change_chan_privacy"
|
PostTypeChangeChannelPrivacy = "system_change_chan_privacy"
|
||||||
|
PostTypeWelcomePost = "system_welcome_post"
|
||||||
PostTypeAddBotTeamsChannels = "add_bot_teams_channels"
|
PostTypeAddBotTeamsChannels = "add_bot_teams_channels"
|
||||||
PostTypeSystemWarnMetricStatus = "warn_metric_status"
|
PostTypeSystemWarnMetricStatus = "warn_metric_status"
|
||||||
PostTypeMe = "me"
|
PostTypeMe = "me"
|
||||||
@@ -387,6 +388,7 @@ func (o *Post) IsValid(maxPostSize int) *AppError {
|
|||||||
PostTypeChangeChannelPrivacy,
|
PostTypeChangeChannelPrivacy,
|
||||||
PostTypeAddBotTeamsChannels,
|
PostTypeAddBotTeamsChannels,
|
||||||
PostTypeSystemWarnMetricStatus,
|
PostTypeSystemWarnMetricStatus,
|
||||||
|
PostTypeWelcomePost,
|
||||||
PostTypeMe:
|
PostTypeMe:
|
||||||
default:
|
default:
|
||||||
if !strings.HasPrefix(o.Type, PostCustomTypePrefix) {
|
if !strings.HasPrefix(o.Type, PostCustomTypePrefix) {
|
||||||
|
|||||||
@@ -10801,6 +10801,24 @@ func (s *OpenTracingLayerUserStore) GetEtagForProfilesNotInTeam(teamID string) s
|
|||||||
return result
|
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) {
|
func (s *OpenTracingLayerUserStore) GetForLogin(loginID string, allowSignInWithUsername bool, allowSignInWithEmail bool) (*model.User, error) {
|
||||||
origCtx := s.Root.Store.Context()
|
origCtx := s.Root.Store.Context()
|
||||||
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.GetForLogin")
|
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.GetForLogin")
|
||||||
|
|||||||
@@ -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) {
|
func (s *RetryLayerUserStore) GetForLogin(loginID string, allowSignInWithUsername bool, allowSignInWithEmail bool) (*model.User, error) {
|
||||||
|
|
||||||
tries := 0
|
tries := 0
|
||||||
|
|||||||
@@ -1748,6 +1748,16 @@ func (us SqlUserStore) InferSystemInstallDate() (int64, error) {
|
|||||||
return createAt, nil
|
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) {
|
func (us SqlUserStore) GetUsersBatchForIndexing(startTime int64, startFileID string, limit int) ([]*model.UserForIndexing, error) {
|
||||||
users := []*model.User{}
|
users := []*model.User{}
|
||||||
usersQuery, args, err := us.usersQuery.
|
usersQuery, args, err := us.usersQuery.
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ type UserStore interface {
|
|||||||
IsEmpty(excludeBots bool) (bool, error)
|
IsEmpty(excludeBots bool) (bool, error)
|
||||||
GetUsersWithInvalidEmails(page int, perPage int, restrictedDomains string) ([]*model.User, error)
|
GetUsersWithInvalidEmails(page int, perPage int, restrictedDomains string) ([]*model.User, error)
|
||||||
InsertUsers(users []*model.User) error
|
InsertUsers(users []*model.User) error
|
||||||
|
GetFirstSystemAdminID() (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type BotStore interface {
|
type BotStore interface {
|
||||||
|
|||||||
@@ -569,6 +569,27 @@ func (_m *UserStore) GetEtagForProfilesNotInTeam(teamID string) string {
|
|||||||
return r0
|
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
|
// GetForLogin provides a mock function with given fields: loginID, allowSignInWithUsername, allowSignInWithEmail
|
||||||
func (_m *UserStore) GetForLogin(loginID string, allowSignInWithUsername bool, allowSignInWithEmail bool) (*model.User, error) {
|
func (_m *UserStore) GetForLogin(loginID string, allowSignInWithUsername bool, allowSignInWithEmail bool) (*model.User, error) {
|
||||||
ret := _m.Called(loginID, allowSignInWithUsername, allowSignInWithEmail)
|
ret := _m.Called(loginID, allowSignInWithUsername, allowSignInWithEmail)
|
||||||
|
|||||||
@@ -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("ResetLastPictureUpdate", func(t *testing.T) { testUserStoreResetLastPictureUpdate(t, ss) })
|
||||||
t.Run("GetKnownUsers", func(t *testing.T) { testGetKnownUsers(t, ss) })
|
t.Run("GetKnownUsers", func(t *testing.T) { testGetKnownUsers(t, ss) })
|
||||||
t.Run("GetUsersWithInvalidEmails", func(t *testing.T) { testGetUsersWithInvalidEmails(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) {
|
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) {
|
func testUserStoreAnalyticsActiveCount(t *testing.T, ss store.Store, s SqlStore) {
|
||||||
|
|
||||||
cleanupStatusStore(t, s)
|
cleanupStatusStore(t, s)
|
||||||
|
|||||||
@@ -9727,6 +9727,22 @@ func (s *TimerLayerUserStore) GetEtagForProfilesNotInTeam(teamID string) string
|
|||||||
return result
|
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) {
|
func (s *TimerLayerUserStore) GetForLogin(loginID string, allowSignInWithUsername bool, allowSignInWithEmail bool) (*model.User, error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user