diff --git a/app/team.go b/app/team.go index 4ffa77858b..9c82d08543 100644 --- a/app/team.go +++ b/app/team.go @@ -6,6 +6,7 @@ package app import ( "bytes" "context" + "crypto/md5" "encoding/json" "errors" "fmt" @@ -17,6 +18,7 @@ import ( "sort" "strings" + fb_model "github.com/mattermost/focalboard/server/model" "github.com/mattermost/mattermost-server/v6/app/email" "github.com/mattermost/mattermost-server/v6/app/imaging" "github.com/mattermost/mattermost-server/v6/app/request" @@ -160,6 +162,128 @@ func (a *App) SoftDeleteAllTeamsExcept(teamID string) *model.AppError { return nil } +// MM-48246 A/B test show linked boards +const preferenceName = "linked_board_created" + +func (a *App) shouldCreateOnboardingLinkedBoard(c request.CTX, teamId string) bool { + ffEnabled := a.Config().FeatureFlags.OnboardingAutoShowLinkedBoard + if !ffEnabled { + return false + } + + hasBoard, err := a.HasBoardProduct() + if err != nil { + a.Log().Error("error checking the existence of boards product: ", mlog.Err(err)) + return false + } + + if !hasBoard { + a.Log().Warn("board product not found") + return false + } + + data, sysValErr := a.Srv().Store().System().GetByName(model.PreferenceOnboarding + "_" + preferenceName) + if sysValErr != nil { + var nfErr *store.ErrNotFound + if errors.As(sysValErr, &nfErr) { // if no board has been registered, it can create one for this team + return true + } + a.Log().Error("cannot get the system values", mlog.Err(sysValErr)) + return false + } + + // get the team list and check if the team value has been already stored, if so, no need to create a board in town square in that team + teamsList := strings.Split(data.Value, ",") + for _, team := range teamsList { + if team == teamId { + return false + } + } + + return true +} + +func (a *App) createOnboardingLinkedBoard(c request.CTX, teamId string) (*fb_model.Board, *model.AppError) { + const defaultTemplatesTeam = "0" + + // see https://github.com/mattermost/focalboard/blob/main/server/services/store/sqlstore/board.go#L302 + // and https://github.com/mattermost/mattermost-server/pull/22201#discussion_r1099536430 + const defaultTemplateTitle = "Welcome to Boards!" + welcomeToBoardsTemplateId := fmt.Sprintf("%x", md5.Sum([]byte(defaultTemplateTitle))) + userId := c.Session().UserId + + boardServiceItf, ok := a.Srv().services[product.BoardsKey] + if !ok { + return nil, model.NewAppError("CreateBoard", "app.team.create_onboarding_linked_board.product_key_not_found", nil, "", http.StatusBadRequest) + } + + boardService, typeOk := boardServiceItf.(product.BoardsService) + if !typeOk { + // boardServiceItf is NOT of type product.BoardsService + return nil, model.NewAppError("CreateBoard", "app.team.create_onboarding_linked_board.itf_not_of_type", nil, "", http.StatusBadRequest) + } + + templates, err := boardService.GetTemplates(defaultTemplatesTeam, userId) + if err != nil { + return nil, model.NewAppError("CreateBoard", "app.team.create_onboarding_linked_board.error_getting_templates", nil, "", http.StatusBadRequest).Wrap(err) + } + + channel, appErr := a.GetChannelByName(c, model.DefaultChannelName, teamId, false) + if appErr != nil { + return nil, appErr + } + + var template *fb_model.Board = nil + for _, t := range templates { + v := t.Properties["trackingTemplateId"] + if v == welcomeToBoardsTemplateId { + template = t + break + } + } + if template == nil && len(templates) > 0 { + template = templates[0] + } + + // Duplicate board From template + boardsAndBlocks, _, err := boardService.DuplicateBoard(template.ID, userId, teamId, false) + if err != nil { + return nil, model.NewAppError("CreateBoard", "app.team.create_onboarding_linked_board.error_duplicating_board", nil, "", http.StatusBadRequest).Wrap(err) + } + if len(boardsAndBlocks.Boards) != 1 { + return nil, model.NewAppError("CreateBoard", "app.team.create_onboarding_linked_board.error_no_board", nil, "", http.StatusBadRequest).Wrap(err) + } + + // link the board with the channel + patchedBoard, err := boardService.PatchBoard(&fb_model.BoardPatch{ + ChannelID: &channel.Id, + }, boardsAndBlocks.Boards[0].ID, userId) + if err != nil && patchedBoard == nil { + return nil, model.NewAppError("CreateBoard", "app.team.create_onboarding_linked_board.error_patching_board", nil, "", http.StatusBadRequest).Wrap(err) + } + + // Save in the system preferences that the board was already created once per team + data, sysValErr := a.Srv().Store().System().GetByName(model.PreferenceOnboarding + "_" + preferenceName) + if sysValErr != nil { + c.Logger().Error("cannot get the system preferences", mlog.Err(sysValErr)) + } + + teamsList := teamId + // if data is not nil, data.Value contains the list of teams where the A/B test has alredy created a channel for town square + if data != nil { + teamsList = data.Value + "," + teamId + } + + if err := a.Srv().Store().System().SaveOrUpdate(&model.System{ + Name: model.PreferenceOnboarding + "_" + preferenceName, + Value: teamsList, + }); err != nil { + c.Logger().Warn("encountered error saving user preferences", mlog.Err(err)) + } + + return patchedBoard, nil +} + func (a *App) CreateTeam(c request.CTX, team *model.Team) (*model.Team, *model.AppError) { rteam, err := a.ch.srv.teamService.CreateTeam(team) if err != nil { @@ -191,6 +315,20 @@ func (a *App) CreateTeam(c request.CTX, team *model.Team) (*model.Team, *model.A } } + // MM-48246 A/B test show linked boards. Create a welcome to boards linked board per user + if a.shouldCreateOnboardingLinkedBoard(c, team.Id) { + board, aErr := a.createOnboardingLinkedBoard(c, team.Id) + if aErr != nil || board == nil { + a.Log().Warn("Error creating the linked board, only team created", mlog.Err(err)) + return rteam, nil + } + + if board.ID != "" { + logInfo := fmt.Sprintf("Board created with id %s and associated to channel %s in team %s", board.ID, board.ChannelID, team.Id) + a.Log().Info(logInfo, mlog.Err(err)) + } + } + return rteam, nil } diff --git a/i18n/en.json b/i18n/en.json index d73c2a0a12..11e34e5cfb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -6495,6 +6495,30 @@ "id": "app.team.clear_cache.app_error", "translation": "Error clearing team member cache" }, + { + "id": "app.team.create_onboarding_linked_board.error_duplicating_board", + "translation": "error duplicating board" + }, + { + "id": "app.team.create_onboarding_linked_board.error_getting_templates", + "translation": "error getting boards templates" + }, + { + "id": "app.team.create_onboarding_linked_board.error_no_board", + "translation": "error no board created" + }, + { + "id": "app.team.create_onboarding_linked_board.error_patching_board", + "translation": "error while patching board" + }, + { + "id": "app.team.create_onboarding_linked_board.itf_not_of_type", + "translation": "interface not of expected type" + }, + { + "id": "app.team.create_onboarding_linked_board.product_key_not_found", + "translation": "product key not found" + }, { "id": "app.team.get.find.app_error", "translation": "Unable to find the existing team." diff --git a/model/feature_flags.go b/model/feature_flags.go index cad64cc752..5d9b88d4c3 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -73,6 +73,9 @@ type FeatureFlags struct { // A/B Test on reduced onboarding task list item ReduceOnBoardingTaskList bool + // A/B Test to control when to show onboarding linked board + OnboardingAutoShowLinkedBoard bool + ThreadsEverywhere bool GlobalDrafts bool @@ -107,6 +110,7 @@ func (f *FeatureFlags) SetDefaults() { f.ThreadsEverywhere = false f.GlobalDrafts = true f.WysiwygEditor = false + f.OnboardingAutoShowLinkedBoard = true f.OnboardingTourTips = true } diff --git a/model/preference.go b/model/preference.go index 98e0a08c5a..df149179e2 100644 --- a/model/preference.go +++ b/model/preference.go @@ -33,6 +33,9 @@ const ( PreferenceRecommendedNextSteps = "recommended_next_steps" PreferenceNameInsights = "insights_tutorial_state" + // initial onboarding preferences + PreferenceOnboarding = "onboarding" + PreferenceCategoryTheme = "theme" // the name for theme props is the team id