2021-11-11 17:01:43 +01:00
|
|
|
package sqlstore
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"fmt"
|
|
|
|
"strconv"
|
|
|
|
|
2022-03-22 15:24:34 +01:00
|
|
|
sq "github.com/Masterminds/squirrel"
|
|
|
|
|
2021-11-11 17:01:43 +01:00
|
|
|
"github.com/mattermost/focalboard/server/model"
|
|
|
|
"github.com/mattermost/focalboard/server/utils"
|
2022-03-22 15:24:34 +01:00
|
|
|
|
2021-11-11 17:01:43 +01:00
|
|
|
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
2022-03-22 15:24:34 +01:00
|
|
|
TemplatesToTeamsMigrationKey = "TemplatesToTeamsMigrationComplete"
|
|
|
|
UniqueIDsMigrationKey = "UniqueIDsMigrationComplete"
|
|
|
|
CategoryUUIDIDMigrationKey = "CategoryUuidIdMigrationComplete"
|
2022-04-27 21:34:02 +02:00
|
|
|
TeamLessBoardsMigrationKey = "TeamLessBoardsMigrationComplete"
|
2021-11-11 17:01:43 +01:00
|
|
|
)
|
|
|
|
|
2022-03-22 15:24:34 +01:00
|
|
|
func (s *SQLStore) getBlocksWithSameID(db sq.BaseRunner) ([]model.Block, error) {
|
|
|
|
subquery, _, _ := s.getQueryBuilder(db).
|
|
|
|
Select("id").
|
|
|
|
From(s.tablePrefix + "blocks").
|
|
|
|
Having("count(id) > 1").
|
|
|
|
GroupBy("id").
|
|
|
|
ToSql()
|
|
|
|
|
|
|
|
blocksFields := []string{
|
|
|
|
"id",
|
|
|
|
"parent_id",
|
|
|
|
"root_id",
|
|
|
|
"created_by",
|
|
|
|
"modified_by",
|
|
|
|
s.escapeField("schema"),
|
|
|
|
"type",
|
|
|
|
"title",
|
|
|
|
"COALESCE(fields, '{}')",
|
|
|
|
s.timestampToCharField("insert_at", "insertAt"),
|
|
|
|
"create_at",
|
|
|
|
"update_at",
|
|
|
|
"delete_at",
|
|
|
|
"COALESCE(workspace_id, '0')",
|
|
|
|
}
|
|
|
|
|
|
|
|
rows, err := s.getQueryBuilder(db).
|
|
|
|
Select(blocksFields...).
|
|
|
|
From(s.tablePrefix + "blocks").
|
|
|
|
Where(fmt.Sprintf("id IN (%s)", subquery)).
|
|
|
|
Query()
|
|
|
|
if err != nil {
|
|
|
|
s.logger.Error(`getBlocksWithSameID ERROR`, mlog.Err(err))
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
defer s.CloseRows(rows)
|
|
|
|
|
|
|
|
return s.blocksFromRows(rows)
|
|
|
|
}
|
|
|
|
|
2021-11-11 17:01:43 +01:00
|
|
|
func (s *SQLStore) runUniqueIDsMigration() error {
|
|
|
|
setting, err := s.GetSystemSetting(UniqueIDsMigrationKey)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot get migration state: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// If the migration is already completed, do not run it again.
|
|
|
|
if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
s.logger.Debug("Running Unique IDs migration")
|
|
|
|
|
|
|
|
tx, txErr := s.db.BeginTx(context.Background(), nil)
|
|
|
|
if txErr != nil {
|
|
|
|
return txErr
|
|
|
|
}
|
|
|
|
|
|
|
|
blocks, err := s.getBlocksWithSameID(tx)
|
|
|
|
if err != nil {
|
|
|
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
2022-03-22 15:24:34 +01:00
|
|
|
s.logger.Error("Unique IDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "getBlocksWithSameID"))
|
2021-11-11 17:01:43 +01:00
|
|
|
}
|
|
|
|
return fmt.Errorf("cannot get blocks with same ID: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
blocksByID := map[string][]model.Block{}
|
|
|
|
for _, block := range blocks {
|
|
|
|
blocksByID[block.ID] = append(blocksByID[block.ID], block)
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, blocks := range blocksByID {
|
|
|
|
for i, block := range blocks {
|
|
|
|
if i == 0 {
|
|
|
|
// do nothing for the first ID, only updating the others
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
newID := utils.NewID(model.BlockType2IDType(block.Type))
|
|
|
|
if err := s.replaceBlockID(tx, block.ID, newID, block.WorkspaceID); err != nil {
|
|
|
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
2022-03-22 15:24:34 +01:00
|
|
|
s.logger.Error("Unique IDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "replaceBlockID"))
|
2021-11-11 17:01:43 +01:00
|
|
|
}
|
|
|
|
return fmt.Errorf("cannot replace blockID %s: %w", block.ID, err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := s.setSystemSetting(tx, UniqueIDsMigrationKey, strconv.FormatBool(true)); err != nil {
|
|
|
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
2022-03-22 15:24:34 +01:00
|
|
|
s.logger.Error("Unique IDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting"))
|
2021-11-11 17:01:43 +01:00
|
|
|
}
|
|
|
|
return fmt.Errorf("cannot mark migration as completed: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
return fmt.Errorf("cannot commit unique IDs transaction: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
s.logger.Debug("Unique IDs migration finished successfully")
|
|
|
|
return nil
|
|
|
|
}
|
2022-03-22 15:24:34 +01:00
|
|
|
|
|
|
|
func (s *SQLStore) runCategoryUUIDIDMigration() error {
|
|
|
|
setting, err := s.GetSystemSetting(CategoryUUIDIDMigrationKey)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot get migration state: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// If the migration is already completed, do not run it again.
|
|
|
|
if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
s.logger.Debug("Running category UUID ID migration")
|
|
|
|
|
|
|
|
tx, txErr := s.db.BeginTx(context.Background(), nil)
|
|
|
|
if txErr != nil {
|
|
|
|
return txErr
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := s.updateCategoryIDs(tx); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := s.updateCategoryBlocksIDs(tx); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := s.setSystemSetting(tx, CategoryUUIDIDMigrationKey, strconv.FormatBool(true)); err != nil {
|
|
|
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
|
|
|
s.logger.Error("category IDs transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "setSystemSetting"))
|
|
|
|
}
|
|
|
|
return fmt.Errorf("cannot mark migration as completed: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
return fmt.Errorf("cannot commit category IDs transaction: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
s.logger.Debug("category IDs migration finished successfully")
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *SQLStore) updateCategoryIDs(db sq.BaseRunner) error {
|
|
|
|
// fetch all category IDs
|
|
|
|
oldCategoryIDs, err := s.getIDs(db, "categories")
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// map old category ID to new ID
|
|
|
|
categoryIDs := map[string]string{}
|
|
|
|
for _, oldID := range oldCategoryIDs {
|
|
|
|
newID := utils.NewID(utils.IDTypeNone)
|
|
|
|
categoryIDs[oldID] = newID
|
|
|
|
}
|
|
|
|
|
|
|
|
// update for each category ID.
|
|
|
|
// Update the new ID in category table,
|
|
|
|
// and update corresponding rows in category boards table.
|
|
|
|
for oldID, newID := range categoryIDs {
|
|
|
|
if err := s.updateCategoryID(db, oldID, newID); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *SQLStore) getIDs(db sq.BaseRunner, table string) ([]string, error) {
|
|
|
|
rows, err := s.getQueryBuilder(db).
|
|
|
|
Select("id").
|
|
|
|
From(s.tablePrefix + table).
|
|
|
|
Query()
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
s.logger.Error("getIDs error", mlog.String("table", table), mlog.Err(err))
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
defer s.CloseRows(rows)
|
|
|
|
var categoryIDs []string
|
|
|
|
for rows.Next() {
|
|
|
|
var id string
|
|
|
|
err := rows.Scan(&id)
|
|
|
|
if err != nil {
|
|
|
|
s.logger.Error("getIDs scan row error", mlog.String("table", table), mlog.Err(err))
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
categoryIDs = append(categoryIDs, id)
|
|
|
|
}
|
|
|
|
|
|
|
|
return categoryIDs, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *SQLStore) updateCategoryID(db sq.BaseRunner, oldID, newID string) error {
|
|
|
|
// update in category table
|
|
|
|
rows, err := s.getQueryBuilder(db).
|
|
|
|
Update(s.tablePrefix+"categories").
|
|
|
|
Set("id", newID).
|
|
|
|
Where(sq.Eq{"id": oldID}).
|
|
|
|
Query()
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
s.logger.Error("updateCategoryID update category error", mlog.Err(err))
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if err = rows.Close(); err != nil {
|
|
|
|
s.logger.Error("updateCategoryID error closing rows after updating categories table IDs", mlog.Err(err))
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// update category boards table
|
|
|
|
|
|
|
|
rows, err = s.getQueryBuilder(db).
|
2022-04-14 00:10:53 +02:00
|
|
|
Update(s.tablePrefix+"category_boards").
|
2022-03-22 15:24:34 +01:00
|
|
|
Set("category_id", newID).
|
|
|
|
Where(sq.Eq{"category_id": oldID}).
|
|
|
|
Query()
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
s.logger.Error("updateCategoryID update category boards error", mlog.Err(err))
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := rows.Close(); err != nil {
|
|
|
|
s.logger.Error("updateCategoryID error closing rows after updating category boards table IDs", mlog.Err(err))
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *SQLStore) updateCategoryBlocksIDs(db sq.BaseRunner) error {
|
|
|
|
// fetch all category IDs
|
2022-04-14 00:10:53 +02:00
|
|
|
oldCategoryIDs, err := s.getIDs(db, "category_boards")
|
2022-03-22 15:24:34 +01:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// map old category ID to new ID
|
|
|
|
categoryIDs := map[string]string{}
|
|
|
|
for _, oldID := range oldCategoryIDs {
|
|
|
|
newID := utils.NewID(utils.IDTypeNone)
|
|
|
|
categoryIDs[oldID] = newID
|
|
|
|
}
|
|
|
|
|
|
|
|
// update for each category ID.
|
|
|
|
// Update the new ID in category table,
|
|
|
|
// and update corresponding rows in category boards table.
|
|
|
|
for oldID, newID := range categoryIDs {
|
|
|
|
if err := s.updateCategoryBlocksID(db, oldID, newID); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *SQLStore) updateCategoryBlocksID(db sq.BaseRunner, oldID, newID string) error {
|
|
|
|
// update in category table
|
|
|
|
rows, err := s.getQueryBuilder(db).
|
2022-04-14 00:10:53 +02:00
|
|
|
Update(s.tablePrefix+"category_boards").
|
2022-03-22 15:24:34 +01:00
|
|
|
Set("id", newID).
|
|
|
|
Where(sq.Eq{"id": oldID}).
|
|
|
|
Query()
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
s.logger.Error("updateCategoryBlocksID update category error", mlog.Err(err))
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
rows.Close()
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
2022-04-27 21:34:02 +02:00
|
|
|
|
|
|
|
// We no longer support boards existing in DMs and private
|
|
|
|
// group messages. This function migrates all boards
|
|
|
|
// belonging to a DM to the best possible team.
|
|
|
|
func (s *SQLStore) migrateTeamLessBoards() error {
|
|
|
|
if !s.isPlugin {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
setting, err := s.GetSystemSetting(TeamLessBoardsMigrationKey)
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("cannot get teamless boards migration state: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// If the migration is already completed, do not run it again.
|
|
|
|
if hasAlreadyRun, _ := strconv.ParseBool(setting); hasAlreadyRun {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
boards, err := s.getDMBoards(s.db)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
s.logger.Info("Migrating teamless boards to a team", mlog.Int("count", len(boards)))
|
|
|
|
|
|
|
|
// cache for best suitable team for a DM. Since a DM can
|
|
|
|
// contain multiple boards, caching this avoids
|
|
|
|
// duplicate queries for the same DM.
|
|
|
|
channelToTeamCache := map[string]string{}
|
|
|
|
|
|
|
|
tx, err := s.db.BeginTx(context.Background(), nil)
|
|
|
|
if err != nil {
|
|
|
|
s.logger.Error("error starting transaction in migrateTeamLessBoards", mlog.Err(err))
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
for i := range boards {
|
|
|
|
// check the cache first
|
|
|
|
teamID, ok := channelToTeamCache[boards[i].ChannelID]
|
|
|
|
|
|
|
|
// query DB if entry not found in cache
|
|
|
|
if !ok {
|
|
|
|
teamID, err = s.getBestTeamForBoard(s.db, boards[i])
|
|
|
|
if err != nil {
|
|
|
|
// don't let one board's error spoil
|
|
|
|
// the mood for others
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
channelToTeamCache[boards[i].ChannelID] = teamID
|
|
|
|
boards[i].TeamID = teamID
|
|
|
|
|
|
|
|
query := s.getQueryBuilder(tx).
|
|
|
|
Update(s.tablePrefix+"boards").
|
|
|
|
Set("team_id", teamID).
|
|
|
|
Set("type", model.BoardTypePrivate).
|
|
|
|
Where(sq.Eq{"id": boards[i].ID})
|
|
|
|
|
|
|
|
if _, err := query.Exec(); err != nil {
|
|
|
|
s.logger.Error("failed to set team id for board", mlog.String("board_id", boards[i].ID), mlog.String("team_id", teamID), mlog.Err(err))
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := s.setSystemSetting(tx, TeamLessBoardsMigrationKey, strconv.FormatBool(true)); err != nil {
|
|
|
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
|
|
|
s.logger.Error("transaction rollback error", mlog.Err(rollbackErr), mlog.String("methodName", "migrateTeamLessBoards"))
|
|
|
|
}
|
|
|
|
return fmt.Errorf("cannot mark migration as completed: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
s.logger.Error("failed to commit migrateTeamLessBoards transaction", mlog.Err(err))
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *SQLStore) getDMBoards(tx sq.BaseRunner) ([]*model.Board, error) {
|
|
|
|
conditions := sq.And{
|
|
|
|
sq.Eq{"team_id": ""},
|
|
|
|
sq.Or{
|
|
|
|
sq.Eq{"type": "D"},
|
|
|
|
sq.Eq{"type": "G"},
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
2022-05-24 09:40:34 +02:00
|
|
|
boards, err := s.getLegacyBoardsByCondition(tx, conditions)
|
2022-04-27 21:34:02 +02:00
|
|
|
if err != nil && model.IsErrNotFound(err) {
|
|
|
|
return []*model.Board{}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return boards, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// The destination is selected as the first team where all members
|
|
|
|
// of the DM are a part of. If no such team exists,
|
|
|
|
// we use the first team to which DM creator belongs to.
|
|
|
|
func (s *SQLStore) getBestTeamForBoard(tx sq.BaseRunner, board *model.Board) (string, error) {
|
|
|
|
userTeams, err := s.getBoardUserTeams(tx, board)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
teams := [][]interface{}{}
|
|
|
|
for _, userTeam := range userTeams {
|
|
|
|
userTeamInterfaces := make([]interface{}, len(userTeam))
|
|
|
|
for i := range userTeam {
|
|
|
|
userTeamInterfaces[i] = userTeam[i]
|
|
|
|
}
|
|
|
|
teams = append(teams, userTeamInterfaces)
|
|
|
|
}
|
|
|
|
|
|
|
|
commonTeams := utils.Intersection(teams...)
|
|
|
|
var teamID string
|
|
|
|
if len(commonTeams) > 0 {
|
|
|
|
teamID = commonTeams[0].(string)
|
|
|
|
} else {
|
|
|
|
// no common teams found. Let's try finding the best suitable team
|
|
|
|
if board.Type == "D" {
|
|
|
|
// get DM's creator and pick one of their team
|
|
|
|
channel, appErr := (*s.pluginAPI).GetChannel(board.ChannelID)
|
|
|
|
if appErr != nil {
|
|
|
|
s.logger.Error("failed to fetch DM channel for board", mlog.String("board_id", board.ID), mlog.String("channel_id", board.ChannelID), mlog.Err(appErr))
|
|
|
|
return "", appErr
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, ok := userTeams[channel.CreatorId]; !ok {
|
|
|
|
err := fmt.Errorf("%w board_id: %s, channel_id: %s, creator_id: %s", errChannelCreatorNotInTeam, board.ID, board.ChannelID, channel.CreatorId)
|
|
|
|
s.logger.Error(err.Error())
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
teamID = userTeams[channel.CreatorId][0]
|
|
|
|
} else if board.Type == "G" {
|
|
|
|
// pick the team that has the most users as members
|
|
|
|
teamFrequency := map[string]int{}
|
|
|
|
highestFrequencyTeam := ""
|
|
|
|
highestFrequencyTeamFrequency := -1
|
|
|
|
|
|
|
|
for _, teams := range userTeams {
|
|
|
|
for _, teamID := range teams {
|
|
|
|
teamFrequency[teamID]++
|
|
|
|
|
|
|
|
if teamFrequency[teamID] > highestFrequencyTeamFrequency {
|
|
|
|
highestFrequencyTeamFrequency = teamFrequency[teamID]
|
|
|
|
highestFrequencyTeam = teamID
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
teamID = highestFrequencyTeam
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return teamID, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *SQLStore) getBoardUserTeams(tx sq.BaseRunner, board *model.Board) (map[string][]string, error) {
|
|
|
|
query := s.getQueryBuilder(tx).
|
|
|
|
Select("TeamMembers.UserId", "TeamMembers.TeamId").
|
|
|
|
From("ChannelMembers").
|
|
|
|
Join("TeamMembers ON ChannelMembers.UserId = TeamMembers.UserId").
|
|
|
|
Where(sq.Eq{"ChannelId": board.ChannelID})
|
|
|
|
|
|
|
|
rows, err := query.Query()
|
|
|
|
if err != nil {
|
|
|
|
s.logger.Error("failed to fetch user teams for board", mlog.String("boardID", board.ID), mlog.String("channelID", board.ChannelID), mlog.Err(err))
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
userTeams := map[string][]string{}
|
|
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
var userID, teamID string
|
|
|
|
err := rows.Scan(&userID, &teamID)
|
|
|
|
if err != nil {
|
|
|
|
s.logger.Error("getBoardUserTeams failed to scan SQL query result", mlog.String("boardID", board.ID), mlog.String("channelID", board.ChannelID), mlog.Err(err))
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
userTeams[userID] = append(userTeams[userID], teamID)
|
|
|
|
}
|
|
|
|
|
|
|
|
return userTeams, nil
|
|
|
|
}
|