From fa6de940704bafea60ff797ed476501d7b46c6aa Mon Sep 17 00:00:00 2001 From: Miguel de la Cruz Date: Wed, 15 Jun 2022 12:17:44 +0200 Subject: [PATCH] Adds limits implementation to the server (#3213) * Adds limits implementation to the server * Add test for deleted boards on active card count --- linux/go.mod | 1 + linux/go.sum | 1 + mattermost-plugin/server/plugin.go | 22 + mattermost-plugin/webapp/src/index.tsx | 6 +- server/api/api.go | 66 +- server/app/app.go | 26 +- server/app/auth_test.go | 2 - server/app/blocks.go | 93 ++- server/app/blocks_test.go | 61 +- server/app/boards.go | 33 + server/app/boards_and_blocks.go | 47 +- server/app/cloud.go | 256 +++++++ server/app/cloud_test.go | 642 ++++++++++++++++++ server/app/import.go | 19 + server/client/client.go | 16 + server/go.mod | 1 + server/go.sum | 1 + server/model/block.go | 34 + server/model/cloud.go | 27 + server/model/error.go | 32 + server/server/params.go | 5 + server/server/server.go | 4 +- .../mattermostauthlayer.go | 4 + server/services/store/mockstore/mockstore.go | 75 ++ server/services/store/sqlstore/blocks.go | 26 + server/services/store/sqlstore/cloud.go | 117 ++++ .../services/store/sqlstore/public_methods.go | 25 + server/services/store/sqlstore/sqlstore.go | 4 + .../services/store/sqlstore/sqlstore_test.go | 1 + server/services/store/sqlstore/util.go | 4 + server/services/store/store.go | 8 + server/services/store/storetests/cloud.go | 331 +++++++++ server/utils/utils.go | 17 +- server/ws/adapter.go | 28 +- server/ws/common.go | 12 + server/ws/plugin_adapter.go | 14 + server/ws/server.go | 10 +- webapp/src/hooks/cardListener.tsx | 1 - webapp/src/octoClient.ts | 9 - webapp/src/octoUtils.test.ts | 45 -- .../pages/boardPage/websocketConnection.tsx | 4 - webapp/src/store/globalTemplates.ts | 2 - webapp/src/wsclient.ts | 21 + 43 files changed, 2035 insertions(+), 118 deletions(-) create mode 100644 server/app/cloud.go create mode 100644 server/app/cloud_test.go create mode 100644 server/model/cloud.go create mode 100644 server/services/store/sqlstore/cloud.go create mode 100644 server/services/store/storetests/cloud.go diff --git a/linux/go.mod b/linux/go.mod index faa052e66..5f8339773 100644 --- a/linux/go.mod +++ b/linux/go.mod @@ -17,6 +17,7 @@ require ( github.com/Masterminds/squirrel v1.5.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect + github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.0 // indirect diff --git a/linux/go.sum b/linux/go.sum index 7bc635eb7..f2501b6fa 100644 --- a/linux/go.sum +++ b/linux/go.sum @@ -210,6 +210,7 @@ github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqO github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/blevesearch/bleve/v2 v2.3.2/go.mod h1:96+xE5pZUOsr3Y4vHzV1cBC837xZCpwLlX0hrrxnvIg= github.com/blevesearch/bleve_index_api v1.0.1/go.mod h1:fiwKS0xLEm+gBRgv5mumf0dhgFr2mDgZah1pqv1c1M4= diff --git a/mattermost-plugin/server/plugin.go b/mattermost-plugin/server/plugin.go index a44833ae0..13e9a3922 100644 --- a/mattermost-plugin/server/plugin.go +++ b/mattermost-plugin/server/plugin.go @@ -18,6 +18,7 @@ import ( "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/services/store/mattermostauthlayer" "github.com/mattermost/focalboard/server/services/store/sqlstore" + "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/focalboard/server/ws" pluginapi "github.com/mattermost/mattermost-plugin-api" @@ -152,6 +153,8 @@ func (p *Plugin) OnActivate() error { WSAdapter: p.wsPluginAdapter, NotifyBackends: notifyBackends, PermissionsService: permissionsService, + PluginAPI: p.API, + Client: client, } server, err := server.New(params) @@ -162,6 +165,19 @@ func (p *Plugin) OnActivate() error { backendParams.appAPI.init(db, server.App()) + if utils.IsCloudLicense(p.API.GetLicense()) { + limits, err := p.API.GetCloudLimits() + if err != nil { + fmt.Println("ERROR FETCHING CLOUD LIMITS WHEN STARTING THE PLUGIN", err) + return err + } + + if err := server.App().SetCloudLimits(limits); err != nil { + fmt.Println("ERROR SETTING CLOUD LIMITS WHEN STARTING THE PLUGIN", err) + return err + } + } + p.server = server return server.Start() } @@ -510,3 +526,9 @@ func isBoardsLink(link string) bool { teamID, boardID, viewID, cardID := returnBoardsParams(pathSplit) return teamID != "" && boardID != "" && viewID != "" && cardID != "" } + +func (p *Plugin) OnCloudLimitsUpdated(limits *mmModel.ProductLimits) { + if err := p.server.App().SetCloudLimits(limits); err != nil { + fmt.Println("Error setting the cloud limits for Boards", err) + } +} diff --git a/mattermost-plugin/webapp/src/index.tsx b/mattermost-plugin/webapp/src/index.tsx index 95c215276..1a4998452 100644 --- a/mattermost-plugin/webapp/src/index.tsx +++ b/mattermost-plugin/webapp/src/index.tsx @@ -39,7 +39,10 @@ import wsClient, { ACTION_UPDATE_BLOCK, ACTION_UPDATE_CLIENT_CONFIG, ACTION_UPDATE_SUBSCRIPTION, - ACTION_UPDATE_CATEGORY, ACTION_UPDATE_BOARD_CATEGORY, ACTION_UPDATE_BOARD, + ACTION_UPDATE_CARD_LIMIT_TIMESTAMP, + ACTION_UPDATE_CATEGORY, + ACTION_UPDATE_BOARD_CATEGORY, + ACTION_UPDATE_BOARD, } from './../../../webapp/src/wsclient' import manifest from './manifest' @@ -275,6 +278,7 @@ export default class Plugin { this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data)) this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data)) this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CLIENT_CONFIG}`, (e: any) => wsClient.updateClientConfigHandler(e.data)) + this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CARD_LIMIT_TIMESTAMP}`, (e: any) => wsClient.updateCardLimitTimestampHandler(e.data)) this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_SUBSCRIPTION}`, (e: any) => wsClient.updateSubscriptionHandler(e.data)) this.registry?.registerWebSocketEventHandler('plugin_statuses_changed', (e: any) => wsClient.pluginStatusesChangedHandler(e.data)) this.registry?.registerWebSocketEventHandler('preferences_changed', (e: any) => { diff --git a/server/api/api.go b/server/api/api.go index c08f456a5..c730fa479 100644 --- a/server/api/api.go +++ b/server/api/api.go @@ -153,6 +153,9 @@ func (a *API) RegisterRoutes(r *mux.Router) { apiv2.HandleFunc("/boards/{boardID}/archive/export", a.sessionRequired(a.handleArchiveExportBoard)).Methods("GET") apiv2.HandleFunc("/teams/{teamID}/archive/import", a.sessionRequired(a.handleArchiveImport)).Methods("POST") + // limits + apiv2.HandleFunc("/limits", a.sessionRequired(a.handleCloudLimits)).Methods("GET") + // System APIs r.HandleFunc("/hello", a.handleHello).Methods("GET") } @@ -375,6 +378,13 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) { mlog.Int("block_count", len(blocks)), ) + var bErr error + blocks, bErr = a.app.ApplyCloudLimits(blocks) + if bErr != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", bErr) + return + } + json, err := json.Marshal(blocks) if err != nil { a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) @@ -875,7 +885,12 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) { newBlocks, err := a.app.InsertBlocks(blocks, session.UserID, true) if err != nil { - a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) + if errors.Is(err, app.ErrViewsLimitReached) { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, err.Error(), err) + } else { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) + } + return } @@ -1408,6 +1423,10 @@ func (a *API) handlePatchBlock(w http.ResponseWriter, r *http.Request) { auditRec.AddMeta("blockID", blockID) err = a.app.PatchBlock(blockID, patch, userID) + if errors.Is(err, app.ErrPatchUpdatesLimitedCards) { + a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", err) + return + } if err != nil { a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) return @@ -1489,6 +1508,10 @@ func (a *API) handlePatchBlocks(w http.ResponseWriter, r *http.Request) { } err = a.app.PatchBlocks(teamID, patches, userID) + if errors.Is(err, app.ErrPatchUpdatesLimitedCards) { + a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", err) + return + } if err != nil { a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) return @@ -1830,7 +1853,7 @@ func (a *API) handlePostTeamRegenerateSignupToken(w http.ResponseWriter, r *http // File upload func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) { - // swagger:operation GET "api/v2/files/teams/{teamID}/{boardID}/{filename} getFile + // swagger:operation GET /files/teams/{teamID}/{boardID}/{filename} getFile // // Returns the contents of an uploaded file // @@ -4002,6 +4025,10 @@ func (a *API) handlePatchBoardsAndBlocks(w http.ResponseWriter, r *http.Request) auditRec.AddMeta("blocksCount", len(pbab.BlockIDs)) bab, err := a.app.PatchBoardsAndBlocks(pbab, userID) + if errors.Is(err, app.ErrPatchUpdatesLimitedCards) { + a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", err) + return + } if err != nil { a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) return @@ -4141,6 +4168,41 @@ func (a *API) handleDeleteBoardsAndBlocks(w http.ResponseWriter, r *http.Request auditRec.Success() } +func (a *API) handleCloudLimits(w http.ResponseWriter, r *http.Request) { + // swagger:operation GET /limits cloudLimits + // + // Fetches the cloud limits of the server. + // + // --- + // produces: + // - application/json + // security: + // - BearerAuth: [] + // responses: + // '200': + // description: success + // schema: + // "$ref": "#/definitions/BoardsCloudLimits" + // default: + // description: internal error + // schema: + // "$ref": "#/definitions/ErrorResponse" + + boardsCloudLimits, err := a.app.GetBoardsCloudLimits() + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) + return + } + + data, err := json.Marshal(boardsCloudLimits) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) + return + } + + jsonBytesResponse(w, http.StatusOK, data) +} + func (a *API) handleHello(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /hello hello // diff --git a/server/app/app.go b/server/app/app.go index d95bb57b9..502ad6fae 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -1,6 +1,7 @@ package app import ( + "sync" "time" "github.com/mattermost/focalboard/server/auth" @@ -13,9 +14,10 @@ import ( "github.com/mattermost/focalboard/server/utils" "github.com/mattermost/focalboard/server/ws" - "github.com/mattermost/mattermost-server/v6/shared/mlog" + mmModel "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/filestore" + "github.com/mattermost/mattermost-server/v6/shared/mlog" ) const ( @@ -24,6 +26,10 @@ const ( blockChangeNotifierShutdownTimeout = time.Second * 10 ) +type pluginAPI interface { + GetUsers(options *mmModel.UserGetOptions) ([]*mmModel.User, *mmModel.AppError) +} + type Services struct { Auth *auth.Auth Store store.Store @@ -34,6 +40,7 @@ type Services struct { Logger *mlog.Logger Permissions permissions.PermissionsService SkipTemplateInit bool + PluginAPI pluginAPI } type App struct { @@ -47,6 +54,10 @@ type App struct { notifications *notify.Service logger *mlog.Logger blockChangeNotifier *utils.CallbackQueue + pluginAPI pluginAPI + + cardLimitMux sync.RWMutex + cardLimit int } func (a *App) SetConfig(config *config.Configuration) { @@ -69,7 +80,20 @@ func New(config *config.Configuration, wsAdapter ws.Adapter, services Services) notifications: services.Notifications, logger: services.Logger, blockChangeNotifier: utils.NewCallbackQueue("blockChangeNotifier", blockChangeNotifierQueueSize, blockChangeNotifierPoolSize, services.Logger), + pluginAPI: services.PluginAPI, } app.initialize(services.SkipTemplateInit) return app } + +func (a *App) CardLimit() int { + a.cardLimitMux.RLock() + defer a.cardLimitMux.RUnlock() + return a.cardLimit +} + +func (a *App) SetCardLimit(cardLimit int) { + a.cardLimitMux.Lock() + defer a.cardLimitMux.Unlock() + a.cardLimit = cardLimit +} diff --git a/server/app/auth_test.go b/server/app/auth_test.go index c7dd67bd9..82e81a5c5 100644 --- a/server/app/auth_test.go +++ b/server/app/auth_test.go @@ -1,7 +1,6 @@ package app import ( - "fmt" "testing" "github.com/golang/mock/gomock" @@ -114,7 +113,6 @@ func TestRegisterUser(t *testing.T) { for _, test := range testcases { t.Run(test.title, func(t *testing.T) { - fmt.Println(test.email) err := th.App.RegisterUser(test.userName, test.email, test.password) if test.isError { require.Error(t, err) diff --git a/server/app/blocks.go b/server/app/blocks.go index 14ce67be2..97e5186a5 100644 --- a/server/app/blocks.go +++ b/server/app/blocks.go @@ -13,6 +13,8 @@ import ( ) var ErrBlocksFromMultipleBoards = errors.New("the block set contain blocks from multiple boards") +var ErrViewsLimitReached = errors.New("views limit reached for board") +var ErrPatchUpdatesLimitedCards = errors.New("patch updates cards that are limited") func (a *App) GetBlocks(boardID, parentID string, blockType string) ([]model.Block, error) { if boardID == "" { @@ -50,6 +52,16 @@ func (a *App) DuplicateBlock(boardID string, blockID string, userID string, asTe } return nil }) + + go func() { + if uErr := a.UpdateCardLimitTimestamp(); uErr != nil { + a.logger.Error( + "UpdateCardLimitTimestamp failed duplicating a block", + mlog.Err(uErr), + ) + } + }() + return blocks, err } @@ -60,7 +72,17 @@ func (a *App) GetBlocksWithBoardID(boardID string) ([]model.Block, error) { func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedByID string) error { oldBlock, err := a.store.GetBlock(blockID) if err != nil { - return nil + return err + } + + if a.IsCloudLimited() { + containsLimitedBlocks, lErr := a.ContainsLimitedBlocks([]model.Block{*oldBlock}) + if lErr != nil { + return lErr + } + if containsLimitedBlocks { + return ErrPatchUpdatesLimitedCards + } } board, err := a.store.GetBoard(oldBlock.BoardID) @@ -76,7 +98,7 @@ func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedB a.metrics.IncrementBlocksPatched(1) block, err := a.store.GetBlock(blockID) if err != nil { - return nil + return err } a.blockChangeNotifier.Enqueue(func() error { // broadcast on websocket @@ -93,17 +115,22 @@ func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedB } func (a *App) PatchBlocks(teamID string, blockPatches *model.BlockPatchBatch, modifiedByID string) error { - oldBlocks := make([]model.Block, 0, len(blockPatches.BlockIDs)) - for _, blockID := range blockPatches.BlockIDs { - oldBlock, err := a.store.GetBlock(blockID) - if err != nil { - return nil - } - oldBlocks = append(oldBlocks, *oldBlock) + oldBlocks, err := a.store.GetBlocksByIDs(blockPatches.BlockIDs) + if err != nil { + return err } - err := a.store.PatchBlocks(blockPatches, modifiedByID) - if err != nil { + if a.IsCloudLimited() { + containsLimitedBlocks, err := a.ContainsLimitedBlocks(oldBlocks) + if err != nil { + return err + } + if containsLimitedBlocks { + return ErrPatchUpdatesLimitedCards + } + } + + if err := a.store.PatchBlocks(blockPatches, modifiedByID); err != nil { return err } @@ -112,7 +139,7 @@ func (a *App) PatchBlocks(teamID string, blockPatches *model.BlockPatchBatch, mo for i, blockID := range blockPatches.BlockIDs { newBlock, err := a.store.GetBlock(blockID) if err != nil { - return nil + return err } a.wsAdapter.BroadcastBlockChange(teamID, *newBlock) a.webhook.NotifyUpdate(*newBlock) @@ -136,9 +163,20 @@ func (a *App) InsertBlock(block model.Block, modifiedByID string) error { a.metrics.IncrementBlocksInserted(1) a.webhook.NotifyUpdate(block) a.notifyBlockChanged(notify.Add, &block, nil, modifiedByID) + return nil }) } + + go func() { + if uErr := a.UpdateCardLimitTimestamp(); uErr != nil { + a.logger.Error( + "UpdateCardLimitTimestamp failed after inserting a block", + mlog.Err(uErr), + ) + } + }() + return err } @@ -180,9 +218,19 @@ func (a *App) InsertBlocks(blocks []model.Block, modifiedByID string, allowNotif a.notifyBlockChanged(notify.Add, &block, nil, modifiedByID) } } + return nil }) + go func() { + if err := a.UpdateCardLimitTimestamp(); err != nil { + a.logger.Error( + "UpdateCardLimitTimestamp failed after inserting blocks", + mlog.Err(err), + ) + } + }() + return blocks, nil } @@ -289,8 +337,19 @@ func (a *App) DeleteBlock(blockID string, modifiedBy string) error { a.wsAdapter.BroadcastBlockDelete(board.TeamID, blockID, block.BoardID) a.metrics.IncrementBlocksDeleted(1) a.notifyBlockChanged(notify.Delete, block, block, modifiedBy) + return nil }) + + go func() { + if err := a.UpdateCardLimitTimestamp(); err != nil { + a.logger.Error( + "UpdateCardLimitTimestamp failed after deleting a block", + mlog.Err(err), + ) + } + }() + return nil } @@ -341,9 +400,19 @@ func (a *App) UndeleteBlock(blockID string, modifiedBy string) (*model.Block, er a.metrics.IncrementBlocksInserted(1) a.webhook.NotifyUpdate(*block) a.notifyBlockChanged(notify.Add, block, nil, modifiedBy) + return nil }) + go func() { + if err := a.UpdateCardLimitTimestamp(); err != nil { + a.logger.Error( + "UpdateCardLimitTimestamp failed after undeleting a block", + mlog.Err(err), + ) + } + }() + return block, nil } diff --git a/server/app/blocks_test.go b/server/app/blocks_test.go index e86057a7f..1556176ea 100644 --- a/server/app/blocks_test.go +++ b/server/app/blocks_test.go @@ -1,12 +1,15 @@ package app import ( + "database/sql" "testing" "github.com/golang/mock/gomock" - "github.com/mattermost/focalboard/server/model" - "github.com/stretchr/testify/require" + + mmModel "github.com/mattermost/mattermost-server/v6/model" + + "github.com/mattermost/focalboard/server/model" ) type blockError struct { @@ -48,17 +51,63 @@ func TestPatchBlocks(t *testing.T) { defer tearDown() t.Run("patchBlocks success scenerio", func(t *testing.T) { - blockPatches := model.BlockPatchBatch{} + blockPatches := model.BlockPatchBatch{ + BlockIDs: []string{"block1"}, + BlockPatches: []model.BlockPatch{ + {Title: mmModel.NewString("new title")}, + }, + } + + block1 := model.Block{ID: "block1"} + th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]model.Block{block1}, nil) th.Store.EXPECT().PatchBlocks(gomock.Eq(&blockPatches), gomock.Eq("user-id-1")).Return(nil) + th.Store.EXPECT().GetBlock("block1").Return(&block1, nil) + // this call comes from the WS server notification + th.Store.EXPECT().GetMembersForBoard(gomock.Any()).Times(1) err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1") require.NoError(t, err) }) t.Run("patchBlocks error scenerio", func(t *testing.T) { - blockPatches := model.BlockPatchBatch{} - th.Store.EXPECT().PatchBlocks(gomock.Eq(&blockPatches), gomock.Eq("user-id-1")).Return(blockError{"error"}) + blockPatches := model.BlockPatchBatch{BlockIDs: []string{}} + th.Store.EXPECT().GetBlocksByIDs([]string{}).Return(nil, sql.ErrNoRows) err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1") - require.Error(t, err, "error") + require.ErrorIs(t, err, sql.ErrNoRows) + }) + + t.Run("cloud limit error scenario", func(t *testing.T) { + th.App.SetCardLimit(5) + + fakeLicense := &mmModel.License{ + Features: &mmModel.Features{Cloud: mmModel.NewBool(true)}, + } + + blockPatches := model.BlockPatchBatch{ + BlockIDs: []string{"block1"}, + BlockPatches: []model.BlockPatch{ + {Title: mmModel.NewString("new title")}, + }, + } + + block1 := model.Block{ + ID: "block1", + Type: model.TypeCard, + ParentID: "board-id", + BoardID: "board-id", + UpdateAt: 100, + } + + board1 := &model.Board{ + ID: "board-id", + Type: model.BoardTypeOpen, + } + + th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]model.Block{block1}, nil) + th.Store.EXPECT().GetBoard("board-id").Return(board1, nil) + th.Store.EXPECT().GetLicense().Return(fakeLicense) + th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(150), nil) + err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1") + require.ErrorIs(t, err, ErrPatchUpdatesLimitedCards) }) } diff --git a/server/app/boards.go b/server/app/boards.go index 672c81fc6..33351151b 100644 --- a/server/app/boards.go +++ b/server/app/boards.go @@ -1,3 +1,6 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + package app import ( @@ -199,6 +202,18 @@ func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (* } return nil }) + + if len(bab.Blocks) != 0 { + go func() { + if uErr := a.UpdateCardLimitTimestamp(); uErr != nil { + a.logger.Error( + "UpdateCardLimitTimestamp failed after duplicating a board", + mlog.Err(uErr), + ) + } + }() + } + return bab, members, err } @@ -273,6 +288,15 @@ func (a *App) DeleteBoard(boardID, userID string) error { return nil }) + go func() { + if err := a.UpdateCardLimitTimestamp(); err != nil { + a.logger.Error( + "UpdateCardLimitTimestamp failed after deleting a board", + mlog.Err(err), + ) + } + }() + return nil } @@ -451,5 +475,14 @@ func (a *App) UndeleteBoard(boardID string, modifiedBy string) error { return nil }) + go func() { + if err := a.UpdateCardLimitTimestamp(); err != nil { + a.logger.Error( + "UpdateCardLimitTimestamp failed after undeleting a board", + mlog.Err(err), + ) + } + }() + return nil } diff --git a/server/app/boards_and_blocks.go b/server/app/boards_and_blocks.go index 1208d4676..3b8b36b43 100644 --- a/server/app/boards_and_blocks.go +++ b/server/app/boards_and_blocks.go @@ -44,17 +44,39 @@ func (a *App) CreateBoardsAndBlocks(bab *model.BoardsAndBlocks, userID string, a } } + if len(newBab.Blocks) != 0 { + go func() { + if uErr := a.UpdateCardLimitTimestamp(); uErr != nil { + a.logger.Error( + "UpdateCardLimitTimestamp failed after creating boards and blocks", + mlog.Err(uErr), + ) + } + }() + } + return newBab, nil } func (a *App) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) { - oldBlocksMap := map[string]*model.Block{} - for _, blockID := range pbab.BlockIDs { - block, err := a.store.GetBlock(blockID) - if err != nil { - return nil, err + oldBlocks, err := a.store.GetBlocksByIDs(pbab.BlockIDs) + if err != nil { + return nil, err + } + + if a.IsCloudLimited() { + containsLimitedBlocks, cErr := a.ContainsLimitedBlocks(oldBlocks) + if cErr != nil { + return nil, cErr } - oldBlocksMap[blockID] = block + if containsLimitedBlocks { + return nil, ErrPatchUpdatesLimitedCards + } + } + + oldBlocksMap := map[string]model.Block{} + for _, block := range oldBlocks { + oldBlocksMap[block.ID] = block } bab, err := a.store.PatchBoardsAndBlocks(pbab, userID) @@ -76,7 +98,7 @@ func (a *App) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks, userID stri a.metrics.IncrementBlocksPatched(1) a.wsAdapter.BroadcastBlockChange(teamID, b) a.webhook.NotifyUpdate(b) - a.notifyBlockChanged(notify.Update, &b, oldBlock, userID) + a.notifyBlockChanged(notify.Update, &b, &oldBlock, userID) } for _, board := range bab.Boards { @@ -122,5 +144,16 @@ func (a *App) DeleteBoardsAndBlocks(dbab *model.DeleteBoardsAndBlocks, userID st return nil }) + if len(dbab.Blocks) != 0 { + go func() { + if uErr := a.UpdateCardLimitTimestamp(); uErr != nil { + a.logger.Error( + "UpdateCardLimitTimestamp failed after deleting boards and blocks", + mlog.Err(uErr), + ) + } + }() + } + return nil } diff --git a/server/app/cloud.go b/server/app/cloud.go new file mode 100644 index 000000000..ee9eed455 --- /dev/null +++ b/server/app/cloud.go @@ -0,0 +1,256 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "errors" + "fmt" + + mmModel "github.com/mattermost/mattermost-server/v6/model" + + "github.com/mattermost/focalboard/server/model" + "github.com/mattermost/focalboard/server/utils" +) + +var ErrNilPluginAPI = errors.New("server not running in plugin mode") + +// GetBoardsCloudLimits returns the limits of the server, and an empty +// limits struct if there are no limits set. +func (a *App) GetBoardsCloudLimits() (*model.BoardsCloudLimits, error) { + if !a.IsCloud() { + return &model.BoardsCloudLimits{}, nil + } + + productLimits, err := a.store.GetCloudLimits() + if err != nil { + return nil, err + } + + usedCards, err := a.store.GetUsedCardsCount() + if err != nil { + return nil, err + } + + cardLimitTimestamp, err := a.store.GetCardLimitTimestamp() + if err != nil { + return nil, err + } + + boardsCloudLimits := &model.BoardsCloudLimits{ + UsedCards: usedCards, + CardLimitTimestamp: cardLimitTimestamp, + } + if productLimits != nil && productLimits.Boards != nil { + if productLimits.Boards.Cards != nil { + boardsCloudLimits.Cards = *productLimits.Boards.Cards + } + if productLimits.Boards.Views != nil { + boardsCloudLimits.Views = *productLimits.Boards.Views + } + } + + return boardsCloudLimits, nil +} + +// IsCloud returns true if the server is running as a plugin in a +// cloud licensed server. +func (a *App) IsCloud() bool { + return utils.IsCloudLicense(a.store.GetLicense()) +} + +// IsCloudLimited returns true if the server is running in cloud mode +// and the card limit has been set. +func (a *App) IsCloudLimited() bool { + return a.CardLimit() != 0 && a.IsCloud() +} + +// SetCloudLimits sets the limits of the server. +func (a *App) SetCloudLimits(limits *mmModel.ProductLimits) error { + oldCardLimit := a.CardLimit() + + // if the limit object doesn't come complete, we assume limits are + // being disabled + cardLimit := 0 + if limits != nil && limits.Boards != nil && limits.Boards.Cards != nil { + cardLimit = *limits.Boards.Cards + } + + if oldCardLimit != cardLimit { + a.SetCardLimit(cardLimit) + return a.doUpdateCardLimitTimestamp() + } + + return nil +} + +// doUpdateCardLimitTimestamp performs the update without running any +// checks. +func (a *App) doUpdateCardLimitTimestamp() error { + cardLimitTimestamp, err := a.store.UpdateCardLimitTimestamp(a.CardLimit()) + if err != nil { + return err + } + + a.wsAdapter.BroadcastCardLimitTimestampChange(cardLimitTimestamp) + + return nil +} + +// UpdateCardLimitTimestamp checks if the server is a cloud instance +// with limits applied, and if that's true, recalculates the card +// limit timestamp and propagates the new one to the connected +// clients. +func (a *App) UpdateCardLimitTimestamp() error { + if !a.IsCloudLimited() { + return nil + } + + return a.doUpdateCardLimitTimestamp() +} + +// getTemplateMapForBlocks gets all board ids for the blocks, and +// builds a map with the board IDs as the key and their isTemplate +// field as the value. +func (a *App) getTemplateMapForBlocks(blocks []model.Block) (map[string]bool, error) { + boardMap := map[string]*model.Board{} + for _, block := range blocks { + if _, ok := boardMap[block.BoardID]; !ok { + board, err := a.store.GetBoard(block.BoardID) + if err != nil { + return nil, err + } + boardMap[block.BoardID] = board + } + } + + templateMap := map[string]bool{} + for boardID, board := range boardMap { + templateMap[boardID] = board.IsTemplate + } + + return templateMap, nil +} + +// ApplyCloudLimits takes a set of blocks and, if the server is cloud +// limited, limits those that are outside of the card limit and don't +// belong to a template. +func (a *App) ApplyCloudLimits(blocks []model.Block) ([]model.Block, error) { + // if there is no limit currently being applied, return + if !a.IsCloudLimited() { + return blocks, nil + } + + cardLimitTimestamp, err := a.store.GetCardLimitTimestamp() + if err != nil { + return nil, err + } + + templateMap, err := a.getTemplateMapForBlocks(blocks) + if err != nil { + return nil, err + } + + limitedBlocks := make([]model.Block, len(blocks)) + for i, block := range blocks { + // if the block belongs to a template, it will never be + // limited + if isTemplate, ok := templateMap[block.BoardID]; ok && isTemplate { + limitedBlocks[i] = block + continue + } + + if block.ShouldBeLimited(cardLimitTimestamp) { + limitedBlocks[i] = block.GetLimited() + } else { + limitedBlocks[i] = block + } + } + + return limitedBlocks, nil +} + +// ContainsLimitedBlocks checks if a list of blocks contain any block +// that references a limited card. +func (a *App) ContainsLimitedBlocks(blocks []model.Block) (bool, error) { + cardLimitTimestamp, err := a.store.GetCardLimitTimestamp() + if err != nil { + return false, err + } + + if cardLimitTimestamp == 0 { + return false, nil + } + + cards := []model.Block{} + cardIDMap := map[string]bool{} + for _, block := range blocks { + switch block.Type { + case model.TypeCard: + cards = append(cards, block) + default: + cardIDMap[block.ParentID] = true + } + } + + cardIDs := []string{} + // if the card is already present on the set, we don't need to + // fetch it from the database + for cardID := range cardIDMap { + alreadyPresent := false + for _, card := range cards { + if card.ID == cardID { + alreadyPresent = true + break + } + } + + if !alreadyPresent { + cardIDs = append(cardIDs, cardID) + } + } + + if len(cardIDs) > 0 { + fetchedCards, fErr := a.store.GetBlocksByIDs(cardIDs) + if fErr != nil { + return false, fErr + } + cards = append(cards, fetchedCards...) + } + + templateMap, err := a.getTemplateMapForBlocks(cards) + if err != nil { + return false, err + } + + for _, card := range cards { + isTemplate, ok := templateMap[card.BoardID] + if !ok { + return false, newErrBoardNotFoundInTemplateMap(card.BoardID) + } + + // if the block belongs to a template, it will never be + // limited + if isTemplate { + continue + } + + if card.ShouldBeLimited(cardLimitTimestamp) { + return true, nil + } + } + + return false, nil +} + +type errBoardNotFoundInTemplateMap struct { + id string +} + +func newErrBoardNotFoundInTemplateMap(id string) *errBoardNotFoundInTemplateMap { + return &errBoardNotFoundInTemplateMap{id} +} + +func (eb *errBoardNotFoundInTemplateMap) Error() string { + return fmt.Sprintf("board %q not found in template map", eb.id) +} diff --git a/server/app/cloud_test.go b/server/app/cloud_test.go new file mode 100644 index 000000000..62027ae31 --- /dev/null +++ b/server/app/cloud_test.go @@ -0,0 +1,642 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "database/sql" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + + mmModel "github.com/mattermost/mattermost-server/v6/model" + + "github.com/mattermost/focalboard/server/model" +) + +func TestIsCloud(t *testing.T) { + t.Run("if it's not running on plugin mode", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + th.Store.EXPECT().GetLicense().Return(nil) + require.False(t, th.App.IsCloud()) + }) + + t.Run("if it's running on plugin mode but the license is incomplete", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + fakeLicense := &mmModel.License{} + + th.Store.EXPECT().GetLicense().Return(fakeLicense) + require.False(t, th.App.IsCloud()) + + fakeLicense = &mmModel.License{Features: &mmModel.Features{}} + + th.Store.EXPECT().GetLicense().Return(fakeLicense) + require.False(t, th.App.IsCloud()) + }) + + t.Run("if it's running on plugin mode, with a non-cloud license", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + fakeLicense := &mmModel.License{ + Features: &mmModel.Features{Cloud: mmModel.NewBool(false)}, + } + + th.Store.EXPECT().GetLicense().Return(fakeLicense) + require.False(t, th.App.IsCloud()) + }) + + t.Run("if it's running on plugin mode with a cloud license", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + fakeLicense := &mmModel.License{ + Features: &mmModel.Features{Cloud: mmModel.NewBool(true)}, + } + + th.Store.EXPECT().GetLicense().Return(fakeLicense) + require.True(t, th.App.IsCloud()) + }) +} + +func TestIsCloudLimited(t *testing.T) { + t.Run("if no limit has been set, it should be false", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + require.Zero(t, th.App.CardLimit()) + require.False(t, th.App.IsCloudLimited()) + }) + + t.Run("if the limit is set, it should be true", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + fakeLicense := &mmModel.License{ + Features: &mmModel.Features{Cloud: mmModel.NewBool(true)}, + } + th.Store.EXPECT().GetLicense().Return(fakeLicense) + + th.App.SetCardLimit(5) + require.True(t, th.App.IsCloudLimited()) + }) +} + +func TestSetCloudLimits(t *testing.T) { + t.Run("if the limits are empty, it should do nothing", func(t *testing.T) { + t.Run("limits empty", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + require.Zero(t, th.App.CardLimit()) + + require.NoError(t, th.App.SetCloudLimits(nil)) + require.Zero(t, th.App.CardLimit()) + }) + + t.Run("limits not empty but board limits empty", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + require.Zero(t, th.App.CardLimit()) + + limits := &mmModel.ProductLimits{} + + require.NoError(t, th.App.SetCloudLimits(limits)) + require.Zero(t, th.App.CardLimit()) + }) + + t.Run("limits not empty but board limits values empty", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + require.Zero(t, th.App.CardLimit()) + + limits := &mmModel.ProductLimits{ + Boards: &mmModel.BoardsLimits{}, + } + + require.NoError(t, th.App.SetCloudLimits(limits)) + require.Zero(t, th.App.CardLimit()) + }) + }) + + t.Run("if the limits are not empty, it should update them and calculate the new timestamp", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + require.Zero(t, th.App.CardLimit()) + + newCardLimitTimestamp := int64(27) + th.Store.EXPECT().UpdateCardLimitTimestamp(5).Return(newCardLimitTimestamp, nil) + + limits := &mmModel.ProductLimits{ + Boards: &mmModel.BoardsLimits{Cards: mmModel.NewInt(5)}, + } + + require.NoError(t, th.App.SetCloudLimits(limits)) + require.Equal(t, 5, th.App.CardLimit()) + }) + + t.Run("if the limits are already set and we unset them, the timestamp will be unset too", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + th.App.SetCardLimit(20) + + th.Store.EXPECT().UpdateCardLimitTimestamp(0) + + require.NoError(t, th.App.SetCloudLimits(nil)) + + require.Zero(t, th.App.CardLimit()) + }) + + t.Run("if the limits are already set and we try to set the same ones again", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + th.App.SetCardLimit(20) + + // the call to update card limit timestamp should not happen + // as the limits didn't change + th.Store.EXPECT().UpdateCardLimitTimestamp(gomock.Any()).Times(0) + + limits := &mmModel.ProductLimits{ + Boards: &mmModel.BoardsLimits{Cards: mmModel.NewInt(20)}, + } + + require.NoError(t, th.App.SetCloudLimits(limits)) + require.Equal(t, 20, th.App.CardLimit()) + }) +} + +func TestUpdateCardLimitTimestamp(t *testing.T) { + fakeLicense := &mmModel.License{ + Features: &mmModel.Features{Cloud: mmModel.NewBool(true)}, + } + + t.Run("if the server is a cloud instance but not limited, it should do nothing", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + require.Zero(t, th.App.CardLimit()) + + // the license check will not be done as the limit not being + // set is enough for the method to return + th.Store.EXPECT().GetLicense().Times(0) + // no call to UpdateCardLimitTimestamp should happen as the + // method should shortcircuit if not cloud limited + th.Store.EXPECT().UpdateCardLimitTimestamp(gomock.Any()).Times(0) + + require.NoError(t, th.App.UpdateCardLimitTimestamp()) + }) + + t.Run("if the server is a cloud instance and the timestamp is set, it should run the update", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + th.App.SetCardLimit(5) + + th.Store.EXPECT().GetLicense().Return(fakeLicense) + // no call to UpdateCardLimitTimestamp should happen as the + // method should shortcircuit if not cloud limited + th.Store.EXPECT().UpdateCardLimitTimestamp(5) + + require.NoError(t, th.App.UpdateCardLimitTimestamp()) + }) +} + +func TestGetTemplateMapForBlocks(t *testing.T) { + t.Run("should fetch the necessary boards from the database", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + board1 := &model.Board{ + ID: "board1", + Type: model.BoardTypeOpen, + IsTemplate: true, + } + + board2 := &model.Board{ + ID: "board2", + Type: model.BoardTypeOpen, + IsTemplate: false, + } + + blocks := []model.Block{ + { + ID: "card1", + Type: model.TypeCard, + ParentID: "board1", + BoardID: "board1", + }, + { + ID: "card2", + Type: model.TypeCard, + ParentID: "board2", + BoardID: "board2", + }, + { + ID: "text2", + Type: model.TypeText, + ParentID: "card2", + BoardID: "board2", + }, + } + + th.Store.EXPECT(). + GetBoard("board1"). + Return(board1, nil). + Times(1) + th.Store.EXPECT(). + GetBoard("board2"). + Return(board2, nil). + Times(1) + + templateMap, err := th.App.getTemplateMapForBlocks(blocks) + require.NoError(t, err) + require.Len(t, templateMap, 2) + require.Contains(t, templateMap, "board1") + require.True(t, templateMap["board1"]) + require.Contains(t, templateMap, "board2") + require.False(t, templateMap["board2"]) + }) + + t.Run("should fail if the board is not in the database", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + blocks := []model.Block{ + { + ID: "card1", + Type: model.TypeCard, + ParentID: "board1", + BoardID: "board1", + }, + { + ID: "card2", + Type: model.TypeCard, + ParentID: "board2", + BoardID: "board2", + }, + } + + th.Store.EXPECT(). + GetBoard("board1"). + Return(nil, sql.ErrNoRows). + Times(1) + + templateMap, err := th.App.getTemplateMapForBlocks(blocks) + require.ErrorIs(t, err, sql.ErrNoRows) + require.Empty(t, templateMap) + }) +} + +func TestApplyCloudLimits(t *testing.T) { + fakeLicense := &mmModel.License{ + Features: &mmModel.Features{Cloud: mmModel.NewBool(true)}, + } + + board1 := &model.Board{ + ID: "board1", + Type: model.BoardTypeOpen, + IsTemplate: false, + } + + template := &model.Board{ + ID: "template", + Type: model.BoardTypeOpen, + IsTemplate: true, + } + + blocks := []model.Block{ + { + ID: "card1", + Type: model.TypeCard, + ParentID: "board1", + BoardID: "board1", + UpdateAt: 100, + }, + { + ID: "text1", + Type: model.TypeText, + ParentID: "card1", + BoardID: "board1", + UpdateAt: 100, + }, + { + ID: "card2", + Type: model.TypeCard, + ParentID: "board1", + BoardID: "board1", + UpdateAt: 200, + }, + { + ID: "card-from-template", + Type: model.TypeCard, + ParentID: "template", + BoardID: "template", + UpdateAt: 1, + }, + } + + t.Run("if the server is not limited, it should return the blocks untouched", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + require.Zero(t, th.App.CardLimit()) + + newBlocks, err := th.App.ApplyCloudLimits(blocks) + require.NoError(t, err) + require.ElementsMatch(t, blocks, newBlocks) + }) + + t.Run("if the server is limited, it should limit the blocks that are beyond the card limit timestamp", func(t *testing.T) { + findBlock := func(blocks []model.Block, id string) model.Block { + for _, block := range blocks { + if block.ID == id { + return block + } + } + require.FailNow(t, "block %s not found", id) + return model.Block{} // this should never be reached + } + + th, tearDown := SetupTestHelper(t) + defer tearDown() + + th.App.SetCardLimit(5) + + th.Store.EXPECT().GetLicense().Return(fakeLicense) + th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(150), nil) + th.Store.EXPECT().GetBoard("board1").Return(board1, nil).Times(1) + th.Store.EXPECT().GetBoard("template").Return(template, nil).Times(1) + + newBlocks, err := th.App.ApplyCloudLimits(blocks) + require.NoError(t, err) + + // should be limited as it's beyond the threshold + require.True(t, findBlock(newBlocks, "card1").Limited) + // only cards are limited + require.False(t, findBlock(newBlocks, "text1").Limited) + // should not be limited as it's not beyond the threshold + require.False(t, findBlock(newBlocks, "card2").Limited) + // cards belonging to templates are never limited + require.False(t, findBlock(newBlocks, "card-from-template").Limited) + }) +} + +func TestContainsLimitedBlocks(t *testing.T) { + // for all the following tests, the timestamp will be set to 150, + // which means that blocks with an UpdateAt set to 100 will be + // outside the active window and possibly limited, and blocks with + // UpdateAt set to 200 will not + + t.Run("should return false if the card limit timestamp is zero", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + blocks := []model.Block{ + { + ID: "card1", + Type: model.TypeCard, + ParentID: "board1", + BoardID: "board1", + UpdateAt: 100, + }, + } + + th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(0), nil) + + containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks) + require.NoError(t, err) + require.False(t, containsLimitedBlocks) + }) + + t.Run("should return true if the block set contains a card that is limited", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + blocks := []model.Block{ + { + ID: "card1", + Type: model.TypeCard, + ParentID: "board1", + BoardID: "board1", + UpdateAt: 100, + }, + } + + board1 := &model.Board{ + ID: "board1", + Type: model.BoardTypePrivate, + } + + th.App.SetCardLimit(500) + cardLimitTimestamp := int64(150) + th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil) + th.Store.EXPECT().GetBoard("board1").Return(board1, nil) + + containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks) + require.NoError(t, err) + require.True(t, containsLimitedBlocks) + }) + + t.Run("should return false if that same block belongs to a template", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + blocks := []model.Block{ + { + ID: "card1", + Type: model.TypeCard, + ParentID: "board1", + BoardID: "board1", + UpdateAt: 100, + }, + } + + board1 := &model.Board{ + ID: "board1", + Type: model.BoardTypeOpen, + IsTemplate: true, + } + + th.App.SetCardLimit(500) + cardLimitTimestamp := int64(150) + th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil) + th.Store.EXPECT().GetBoard("board1").Return(board1, nil) + + containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks) + require.NoError(t, err) + require.False(t, containsLimitedBlocks) + }) + + t.Run("should return true if the block contains a content block that belongs to a card that should be limited", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + blocks := []model.Block{ + { + ID: "text1", + Type: model.TypeText, + ParentID: "card1", + BoardID: "board1", + UpdateAt: 200, + }, + } + + card1 := model.Block{ + ID: "card1", + Type: model.TypeCard, + ParentID: "board1", + BoardID: "board1", + UpdateAt: 100, + } + + board1 := &model.Board{ + ID: "board1", + Type: model.BoardTypeOpen, + } + + th.App.SetCardLimit(500) + cardLimitTimestamp := int64(150) + th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil) + th.Store.EXPECT().GetBlocksByIDs([]string{"card1"}).Return([]model.Block{card1}, nil) + th.Store.EXPECT().GetBoard("board1").Return(board1, nil) + + containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks) + require.NoError(t, err) + require.True(t, containsLimitedBlocks) + }) + + t.Run("should return false if that same block belongs to a card that is inside the active window", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + blocks := []model.Block{ + { + ID: "text1", + Type: model.TypeText, + ParentID: "card1", + BoardID: "board1", + UpdateAt: 200, + }, + } + + card1 := model.Block{ + ID: "card1", + Type: model.TypeCard, + ParentID: "board1", + BoardID: "board1", + UpdateAt: 200, + } + + board1 := &model.Board{ + ID: "board1", + Type: model.BoardTypeOpen, + } + + th.App.SetCardLimit(500) + cardLimitTimestamp := int64(150) + th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil) + th.Store.EXPECT().GetBlocksByIDs([]string{"card1"}).Return([]model.Block{card1}, nil) + th.Store.EXPECT().GetBoard("board1").Return(board1, nil) + + containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks) + require.NoError(t, err) + require.False(t, containsLimitedBlocks) + }) + + t.Run("should reach to the database to fetch the necessary information only in an efficient way", func(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + blocks := []model.Block{ + // a content block that references a card that needs + // fetching + { + ID: "text1", + Type: model.TypeText, + ParentID: "card1", + BoardID: "board1", + UpdateAt: 100, + }, + // a board that needs fetching referenced by a card and a content block + { + ID: "card2", + Type: model.TypeCard, + ParentID: "board2", + BoardID: "board2", + // per timestamp should be limited but the board is a + // template + UpdateAt: 100, + }, + { + ID: "text2", + Type: model.TypeText, + ParentID: "card2", + BoardID: "board2", + UpdateAt: 200, + }, + // a content block that references a card and a board, + // both absent + { + ID: "image3", + Type: model.TypeImage, + ParentID: "card3", + BoardID: "board3", + UpdateAt: 100, + }, + } + + card1 := model.Block{ + ID: "card1", + Type: model.TypeCard, + ParentID: "board1", + BoardID: "board1", + UpdateAt: 200, + } + + card3 := model.Block{ + ID: "card3", + Type: model.TypeCard, + ParentID: "board3", + BoardID: "board3", + UpdateAt: 200, + } + + board1 := &model.Board{ + ID: "board1", + Type: model.BoardTypeOpen, + } + + board2 := &model.Board{ + ID: "board2", + Type: model.BoardTypeOpen, + IsTemplate: true, + } + + board3 := &model.Board{ + ID: "board3", + Type: model.BoardTypePrivate, + } + + th.App.SetCardLimit(500) + cardLimitTimestamp := int64(150) + th.Store.EXPECT().GetCardLimitTimestamp().Return(cardLimitTimestamp, nil) + th.Store.EXPECT().GetBlocksByIDs(gomock.InAnyOrder([]string{"card1", "card3"})).Return([]model.Block{card1, card3}, nil) + th.Store.EXPECT().GetBoard("board1").Return(board1, nil) + th.Store.EXPECT().GetBoard("board2").Return(board2, nil) + th.Store.EXPECT().GetBoard("board3").Return(board3, nil) + + containsLimitedBlocks, err := th.App.ContainsLimitedBlocks(blocks) + require.NoError(t, err) + require.False(t, containsLimitedBlocks) + }) +} diff --git a/server/app/import.go b/server/app/import.go index cd1372d7e..3eed4b0ab 100644 --- a/server/app/import.go +++ b/server/app/import.go @@ -40,6 +40,16 @@ func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error { if err == nil && string(peek) == legacyFileBegin { a.logger.Debug("importing legacy archive") _, errImport := a.ImportBoardJSONL(br, opt) + + go func() { + if err := a.UpdateCardLimitTimestamp(); err != nil { + a.logger.Error( + "UpdateCardLimitTimestamp failed after importing a legacy file", + mlog.Err(err), + ) + } + }() + return errImport } @@ -98,6 +108,15 @@ func (a *App) ImportArchive(r io.Reader, opt model.ImportArchiveOptions) error { mlog.String("dir", dir), mlog.String("filename", filename), ) + + go func() { + if err := a.UpdateCardLimitTimestamp(); err != nil { + a.logger.Error( + "UpdateCardLimitTimestamp failed after importing an archive", + mlog.Err(err), + ) + } + }() } } diff --git a/server/client/client.go b/server/client/client.go index e377967d5..e70adb848 100644 --- a/server/client/client.go +++ b/server/client/client.go @@ -730,3 +730,19 @@ func (c *Client) ImportArchive(teamID string, data io.Reader) *Response { return BuildResponse(r) } + +func (c *Client) GetLimits() (*model.BoardsCloudLimits, *Response) { + r, err := c.DoAPIGet("/limits", "") + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + + var limits *model.BoardsCloudLimits + err = json.NewDecoder(r.Body).Decode(&limits) + if err != nil { + return nil, BuildErrorResponse(r, err) + } + + return limits, BuildResponse(r) +} diff --git a/server/go.mod b/server/go.mod index b22b54c86..41c3e1dbd 100644 --- a/server/go.mod +++ b/server/go.mod @@ -30,6 +30,7 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect + github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.0 // indirect diff --git a/server/go.sum b/server/go.sum index 2323b9cc7..e0a017db2 100644 --- a/server/go.sum +++ b/server/go.sum @@ -210,6 +210,7 @@ github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqO github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/blevesearch/bleve/v2 v2.3.2/go.mod h1:96+xE5pZUOsr3Y4vHzV1cBC837xZCpwLlX0hrrxnvIg= github.com/blevesearch/bleve_index_api v1.0.1/go.mod h1:fiwKS0xLEm+gBRgv5mumf0dhgFr2mDgZah1pqv1c1M4= diff --git a/server/model/block.go b/server/model/block.go index 97674fb08..67020a9b3 100644 --- a/server/model/block.go +++ b/server/model/block.go @@ -62,6 +62,10 @@ type Block struct { // The board id that the block belongs to // required: true BoardID string `json:"boardId"` + + // Indicates if the card is limited + // required: false + Limited bool `json:"limited,omitempty"` } // BlockPatch is a patch for modify blocks @@ -210,3 +214,33 @@ func StampModificationMetadata(userID string, blocks []Block, auditRec *audit.Re } } } + +func (b Block) ShouldBeLimited(cardLimitTimestamp int64) bool { + return b.Type == TypeCard && + b.UpdateAt < cardLimitTimestamp +} + +// Returns a limited version of the block that doesn't contain the +// contents of the block, only its IDs and type. +func (b Block) GetLimited() Block { + newBlock := Block{ + Title: b.Title, + ID: b.ID, + ParentID: b.ParentID, + Schema: b.Schema, + Type: b.Type, + CreateAt: b.CreateAt, + UpdateAt: b.UpdateAt, + DeleteAt: b.DeleteAt, + WorkspaceID: b.WorkspaceID, + Limited: true, + } + + if iconField, ok := b.Fields["icon"]; ok { + newBlock.Fields = map[string]interface{}{ + "icon": iconField, + } + } + + return newBlock +} diff --git a/server/model/cloud.go b/server/model/cloud.go new file mode 100644 index 000000000..e93f66c30 --- /dev/null +++ b/server/model/cloud.go @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +const LimitUnlimited = 0 + +// BoardsCloudLimits is the representation of the limits for the +// Boards server +// swagger:model +type BoardsCloudLimits struct { + // The maximum number of cards on the server + // required: true + Cards int `json:"cards"` + + // The current number of cards on the server + // required: true + UsedCards int `json:"used_cards"` + + // The updated_at timestamp of the limit card + // required: true + CardLimitTimestamp int64 `json:"card_limit_timestamp"` + + // The maximum number of views for each board + // required: true + Views int `json:"views"` +} diff --git a/server/model/error.go b/server/model/error.go index a8586debc..dd03e068b 100644 --- a/server/model/error.go +++ b/server/model/error.go @@ -4,10 +4,15 @@ import ( "database/sql" "errors" "fmt" + "strings" apierrors "github.com/mattermost/mattermost-plugin-api/errors" ) +// ErrBlocksFromDifferentBoards is an error type that can be returned +// when a set of blocks belong to different boards. +var ErrBlocksFromDifferentBoards = errors.New("blocks belong to different boards") + // ErrNotFound is an error type that can be returned by store APIs when a query unexpectedly fetches no records. type ErrNotFound struct { resource string @@ -47,3 +52,30 @@ func IsErrNotFound(err error) bool { // check if this is a plugin API error return errors.Is(err, apierrors.ErrNotFound) } + +// ErrNotAllFound is an error type that can be returned by store APIs +// when a query that should fetch a certain amount of records +// unexpectedly fetches less. +type ErrNotAllFound struct { + resources []string +} + +func NewErrNotAllFound(resources []string) *ErrNotAllFound { + return &ErrNotAllFound{ + resources: resources, + } +} + +func (na *ErrNotAllFound) Error() string { + return fmt.Sprintf("not all instances in {%s} found", strings.Join(na.resources, ", ")) +} + +// IsErrNotAllFound returns true if `err` is or wraps a ErrNotAllFound. +func IsErrNotAllFound(err error) bool { + if err == nil { + return false + } + + var na *ErrNotAllFound + return errors.As(err, &na) +} diff --git a/server/server/params.go b/server/server/params.go index 553d30c03..7a002223e 100644 --- a/server/server/params.go +++ b/server/server/params.go @@ -9,7 +9,10 @@ import ( "github.com/mattermost/focalboard/server/services/store" "github.com/mattermost/focalboard/server/ws" + "github.com/mattermost/mattermost-server/v6/plugin" "github.com/mattermost/mattermost-server/v6/shared/mlog" + + pluginapi "github.com/mattermost/mattermost-plugin-api" ) type Params struct { @@ -21,6 +24,8 @@ type Params struct { WSAdapter ws.Adapter NotifyBackends []notify.Backend PermissionsService permissions.PermissionsService + PluginAPI plugin.API + Client *pluginapi.Client } func (p Params) CheckValid() error { diff --git a/server/server/server.go b/server/server/server.go index 0f1995cd3..fbd59fd91 100644 --- a/server/server/server.go +++ b/server/server/server.go @@ -33,9 +33,8 @@ import ( "github.com/mattermost/focalboard/server/ws" "github.com/oklog/run" - "github.com/mattermost/mattermost-server/v6/shared/mlog" - "github.com/mattermost/mattermost-server/v6/shared/filestore" + "github.com/mattermost/mattermost-server/v6/shared/mlog" ) const ( @@ -138,6 +137,7 @@ func New(params Params) (*Server, error) { Notifications: notificationService, Logger: params.Logger, Permissions: params.PermissionsService, + PluginAPI: params.PluginAPI, SkipTemplateInit: utils.IsRunningUnitTests(), } app := app.New(params.Cfg, wsAdapter, appServices) diff --git a/server/services/store/mattermostauthlayer/mattermostauthlayer.go b/server/services/store/mattermostauthlayer/mattermostauthlayer.go index e8b3b165a..9a7004233 100644 --- a/server/services/store/mattermostauthlayer/mattermostauthlayer.go +++ b/server/services/store/mattermostauthlayer/mattermostauthlayer.go @@ -465,3 +465,7 @@ func (s *MattermostAuthLayer) SaveFileInfo(fileInfo *mmModel.FileInfo) error { func (s *MattermostAuthLayer) GetLicense() *mmModel.License { return s.pluginAPI.GetLicense() } + +func (s *MattermostAuthLayer) GetCloudLimits() (*mmModel.ProductLimits, error) { + return s.pluginAPI.GetCloudLimits() +} diff --git a/server/services/store/mockstore/mockstore.go b/server/services/store/mockstore/mockstore.go index eda5ec3d5..4b4faacdb 100644 --- a/server/services/store/mockstore/mockstore.go +++ b/server/services/store/mockstore/mockstore.go @@ -399,6 +399,21 @@ func (mr *MockStoreMockRecorder) GetBlockHistoryDescendants(arg0, arg1 interface return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlockHistoryDescendants", reflect.TypeOf((*MockStore)(nil).GetBlockHistoryDescendants), arg0, arg1) } +// GetBlocksByIDs mocks base method. +func (m *MockStore) GetBlocksByIDs(arg0 []string) ([]model.Block, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBlocksByIDs", arg0) + ret0, _ := ret[0].([]model.Block) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBlocksByIDs indicates an expected call of GetBlocksByIDs. +func (mr *MockStoreMockRecorder) GetBlocksByIDs(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBlocksByIDs", reflect.TypeOf((*MockStore)(nil).GetBlocksByIDs), arg0) +} + // GetBlocksForBoard mocks base method. func (m *MockStore) GetBlocksForBoard(arg0 string) ([]model.Block, error) { m.ctrl.T.Helper() @@ -566,6 +581,21 @@ func (mr *MockStoreMockRecorder) GetBoardsForUserAndTeam(arg0, arg1 interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBoardsForUserAndTeam", reflect.TypeOf((*MockStore)(nil).GetBoardsForUserAndTeam), arg0, arg1) } +// GetCardLimitTimestamp mocks base method. +func (m *MockStore) GetCardLimitTimestamp() (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCardLimitTimestamp") + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCardLimitTimestamp indicates an expected call of GetCardLimitTimestamp. +func (mr *MockStoreMockRecorder) GetCardLimitTimestamp() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCardLimitTimestamp", reflect.TypeOf((*MockStore)(nil).GetCardLimitTimestamp)) +} + // GetCategory mocks base method. func (m *MockStore) GetCategory(arg0 string) (*model.Category, error) { m.ctrl.T.Helper() @@ -581,6 +611,21 @@ func (mr *MockStoreMockRecorder) GetCategory(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCategory", reflect.TypeOf((*MockStore)(nil).GetCategory), arg0) } +// GetCloudLimits mocks base method. +func (m *MockStore) GetCloudLimits() (*model0.ProductLimits, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCloudLimits") + ret0, _ := ret[0].(*model0.ProductLimits) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCloudLimits indicates an expected call of GetCloudLimits. +func (mr *MockStoreMockRecorder) GetCloudLimits() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCloudLimits", reflect.TypeOf((*MockStore)(nil).GetCloudLimits)) +} + // GetFileInfo mocks base method. func (m *MockStore) GetFileInfo(arg0 string) (*model0.FileInfo, error) { m.ctrl.T.Helper() @@ -895,6 +940,21 @@ func (mr *MockStoreMockRecorder) GetTemplateBoards(arg0, arg1 interface{}) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateBoards", reflect.TypeOf((*MockStore)(nil).GetTemplateBoards), arg0, arg1) } +// GetUsedCardsCount mocks base method. +func (m *MockStore) GetUsedCardsCount() (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUsedCardsCount") + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUsedCardsCount indicates an expected call of GetUsedCardsCount. +func (mr *MockStoreMockRecorder) GetUsedCardsCount() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsedCardsCount", reflect.TypeOf((*MockStore)(nil).GetUsedCardsCount)) +} + // GetUserByEmail mocks base method. func (m *MockStore) GetUserByEmail(arg0 string) (*model.User, error) { m.ctrl.T.Helper() @@ -1259,6 +1319,21 @@ func (mr *MockStoreMockRecorder) UndeleteBoard(arg0, arg1 interface{}) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UndeleteBoard", reflect.TypeOf((*MockStore)(nil).UndeleteBoard), arg0, arg1) } +// UpdateCardLimitTimestamp mocks base method. +func (m *MockStore) UpdateCardLimitTimestamp(arg0 int) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCardLimitTimestamp", arg0) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateCardLimitTimestamp indicates an expected call of UpdateCardLimitTimestamp. +func (mr *MockStoreMockRecorder) UpdateCardLimitTimestamp(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCardLimitTimestamp", reflect.TypeOf((*MockStore)(nil).UpdateCardLimitTimestamp), arg0) +} + // UpdateCategory mocks base method. func (m *MockStore) UpdateCategory(arg0 model.Category) error { m.ctrl.T.Helper() diff --git a/server/services/store/sqlstore/blocks.go b/server/services/store/sqlstore/blocks.go index eca85cb6e..08326e511 100644 --- a/server/services/store/sqlstore/blocks.go +++ b/server/services/store/sqlstore/blocks.go @@ -100,6 +100,32 @@ func (s *SQLStore) getBlocksWithParent(db sq.BaseRunner, boardID, parentID strin return s.blocksFromRows(rows) } +func (s *SQLStore) getBlocksByIDs(db sq.BaseRunner, ids []string) ([]model.Block, error) { + query := s.getQueryBuilder(db). + Select(s.blockFields()...). + From(s.tablePrefix + "blocks"). + Where(sq.Eq{"id": ids}) + + rows, err := query.Query() + if err != nil { + s.logger.Error(`GetBlocksByIDs ERROR`, mlog.Err(err)) + + return nil, err + } + defer s.CloseRows(rows) + + blocks, err := s.blocksFromRows(rows) + if err != nil { + return nil, err + } + + if len(blocks) != len(ids) { + return nil, model.NewErrNotAllFound(ids) + } + + return blocks, nil +} + func (s *SQLStore) getBlocksWithBoardID(db sq.BaseRunner, boardID string) ([]model.Block, error) { query := s.getQueryBuilder(db). Select(s.blockFields()...). diff --git a/server/services/store/sqlstore/cloud.go b/server/services/store/sqlstore/cloud.go new file mode 100644 index 000000000..8696d3367 --- /dev/null +++ b/server/services/store/sqlstore/cloud.go @@ -0,0 +1,117 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + "errors" + "strconv" + + sq "github.com/Masterminds/squirrel" + "github.com/mattermost/focalboard/server/model" + "github.com/mattermost/focalboard/server/services/store" +) + +var ErrInvalidCardLimitValue = errors.New("card limit value is invalid") + +// activeCardsQuery applies the necessary filters to the query for it +// to fetch an active cards window if the cardLimit is set, or all the +// active cards if it's 0. +func (s *SQLStore) activeCardsQuery(builder sq.StatementBuilderType, selectStr string, cardLimit int) sq.SelectBuilder { + query := builder. + Select(selectStr). + From(s.tablePrefix + "blocks b"). + Join(s.tablePrefix + "boards bd on b.board_id=bd.id"). + Where(sq.Eq{ + "b.delete_at": 0, + "b.type": model.TypeCard, + "bd.is_template": false, + }) + + if cardLimit != 0 { + query = query. + Limit(1). + Offset(uint64(cardLimit - 1)) + } + + return query +} + +// getUsedCardsCount returns the amount of active cards in the server. +func (s *SQLStore) getUsedCardsCount(db sq.BaseRunner) (int, error) { + row := s.activeCardsQuery(s.getQueryBuilder(db), "count(b.id)", 0). + QueryRow() + + var usedCards int + err := row.Scan(&usedCards) + if err != nil { + return 0, err + } + + return usedCards, nil +} + +// getCardLimitTimestamp returns the timestamp value from the +// system_settings table or zero if it doesn't exist. +func (s *SQLStore) getCardLimitTimestamp(db sq.BaseRunner) (int64, error) { + scanner := s.getQueryBuilder(db). + Select("value"). + From(s.tablePrefix + "system_settings"). + Where(sq.Eq{"id": store.CardLimitTimestampSystemKey}). + QueryRow() + + var result string + err := scanner.Scan(&result) + if errors.Is(sql.ErrNoRows, err) { + return 0, nil + } + if err != nil { + return 0, err + } + + cardLimitTimestamp, err := strconv.Atoi(result) + if err != nil { + return 0, ErrInvalidCardLimitValue + } + + return int64(cardLimitTimestamp), nil +} + +// updateCardLimitTimestamp updates the card limit value in the +// system_settings table with the timestamp of the nth last updated +// card, being nth the value of the cardLimit parameter. If cardLimit +// is zero, the timestamp will be set to zero. +func (s *SQLStore) updateCardLimitTimestamp(db sq.BaseRunner, cardLimit int) (int64, error) { + query := s.getQueryBuilder(db). + Insert(s.tablePrefix+"system_settings"). + Columns("id", "value") + + var value interface{} = 0 + if cardLimit != 0 { + value = s.activeCardsQuery(sq.StatementBuilder, "b.update_at", cardLimit). + OrderBy("b.update_at DESC"). + Prefix("COALESCE((").Suffix("), 0)") + } + query = query.Values(store.CardLimitTimestampSystemKey, value) + + if s.dbType == model.MysqlDBType { + query = query.Suffix("ON DUPLICATE KEY UPDATE value = ?", value) + } else { + query = query.Suffix( + `ON CONFLICT (id) + DO UPDATE SET value = EXCLUDED.value`, + ) + } + + result, err := query.Exec() + if err != nil { + return 0, err + } + + if _, err := result.RowsAffected(); err != nil { + return 0, err + } + + return s.getCardLimitTimestamp(db) +} diff --git a/server/services/store/sqlstore/public_methods.go b/server/services/store/sqlstore/public_methods.go index 16b7b2599..f87eaeef7 100644 --- a/server/services/store/sqlstore/public_methods.go +++ b/server/services/store/sqlstore/public_methods.go @@ -294,6 +294,11 @@ func (s *SQLStore) GetBlockHistoryDescendants(boardID string, opts model.QueryBl } +func (s *SQLStore) GetBlocksByIDs(ids []string) ([]model.Block, error) { + return s.getBlocksByIDs(s.db, ids) + +} + func (s *SQLStore) GetBlocksForBoard(boardID string) ([]model.Block, error) { return s.getBlocksForBoard(s.db, boardID) @@ -349,11 +354,21 @@ func (s *SQLStore) GetBoardsForUserAndTeam(userID string, teamID string) ([]*mod } +func (s *SQLStore) GetCardLimitTimestamp() (int64, error) { + return s.getCardLimitTimestamp(s.db) + +} + func (s *SQLStore) GetCategory(id string) (*model.Category, error) { return s.getCategory(s.db, id) } +func (s *SQLStore) GetCloudLimits() (*mmModel.ProductLimits, error) { + return s.getCloudLimits(s.db) + +} + func (s *SQLStore) GetFileInfo(id string) (*mmModel.FileInfo, error) { return s.getFileInfo(s.db, id) @@ -459,6 +474,11 @@ func (s *SQLStore) GetTemplateBoards(teamID string, userID string) ([]*model.Boa } +func (s *SQLStore) GetUsedCardsCount() (int, error) { + return s.getUsedCardsCount(s.db) + +} + func (s *SQLStore) GetUserByEmail(email string) (*model.User, error) { return s.getUserByEmail(s.db, email) @@ -769,6 +789,11 @@ func (s *SQLStore) UndeleteBoard(boardID string, modifiedBy string) error { } +func (s *SQLStore) UpdateCardLimitTimestamp(cardLimit int) (int64, error) { + return s.updateCardLimitTimestamp(s.db, cardLimit) + +} + func (s *SQLStore) UpdateCategory(category model.Category) error { return s.updateCategory(s.db, category) diff --git a/server/services/store/sqlstore/sqlstore.go b/server/services/store/sqlstore/sqlstore.go index 6f3c48de3..63137f088 100644 --- a/server/services/store/sqlstore/sqlstore.go +++ b/server/services/store/sqlstore/sqlstore.go @@ -122,3 +122,7 @@ func (s *SQLStore) escapeField(fieldName string) string { func (s *SQLStore) getLicense(db sq.BaseRunner) *mmModel.License { return nil } + +func (s *SQLStore) getCloudLimits(db sq.BaseRunner) (*mmModel.ProductLimits, error) { + return nil, nil +} diff --git a/server/services/store/sqlstore/sqlstore_test.go b/server/services/store/sqlstore/sqlstore_test.go index 3bf0c763d..6aed21a06 100644 --- a/server/services/store/sqlstore/sqlstore_test.go +++ b/server/services/store/sqlstore/sqlstore_test.go @@ -21,5 +21,6 @@ func TestSQLStore(t *testing.T) { t.Run("SubscriptionStore", func(t *testing.T) { storetests.StoreTestSubscriptionsStore(t, SetupTests) }) t.Run("NotificationHintStore", func(t *testing.T) { storetests.StoreTestNotificationHintsStore(t, SetupTests) }) t.Run("DataRetention", func(t *testing.T) { storetests.StoreTestDataRetention(t, SetupTests) }) + t.Run("CloudStore", func(t *testing.T) { storetests.StoreTestCloudStore(t, SetupTests) }) t.Run("StoreTestFileStore", func(t *testing.T) { storetests.StoreTestFileStore(t, SetupTests) }) } diff --git a/server/services/store/sqlstore/util.go b/server/services/store/sqlstore/util.go index d54093e2d..b7f6fa735 100644 --- a/server/services/store/sqlstore/util.go +++ b/server/services/store/sqlstore/util.go @@ -24,6 +24,10 @@ func (s *SQLStore) IsErrNotFound(err error) bool { return model.IsErrNotFound(err) } +func (s *SQLStore) IsErrNotAllFound(err error) bool { + return model.IsErrNotAllFound(err) +} + func (s *SQLStore) MarshalJSONB(data interface{}) ([]byte, error) { b, err := json.Marshal(data) if err != nil { diff --git a/server/services/store/store.go b/server/services/store/store.go index 61c042372..7d5a9e618 100644 --- a/server/services/store/store.go +++ b/server/services/store/store.go @@ -10,10 +10,13 @@ import ( mmModel "github.com/mattermost/mattermost-server/v6/model" ) +const CardLimitTimestampSystemKey = "card_limit_timestamp" + // Store represents the abstraction of the data storage. type Store interface { GetBlocksWithParentAndType(boardID, parentID string, blockType string) ([]model.Block, error) GetBlocksWithParent(boardID, parentID string) ([]model.Block, error) + GetBlocksByIDs(ids []string) ([]model.Block, error) GetBlocksWithBoardID(boardID string) ([]model.Block, error) GetBlocksWithType(boardID, blockType string) ([]model.Block, error) GetSubTree2(boardID, blockID string, opts model.QuerySubtreeOptions) ([]model.Block, error) @@ -139,7 +142,12 @@ type Store interface { // @withTransaction RunDataRetention(globalRetentionDate int64, batchSize int64) (int64, error) + GetUsedCardsCount() (int, error) + GetCardLimitTimestamp() (int64, error) + UpdateCardLimitTimestamp(cardLimit int) (int64, error) + DBType() string GetLicense() *mmModel.License + GetCloudLimits() (*mmModel.ProductLimits, error) } diff --git a/server/services/store/storetests/cloud.go b/server/services/store/storetests/cloud.go new file mode 100644 index 000000000..6d8735b17 --- /dev/null +++ b/server/services/store/storetests/cloud.go @@ -0,0 +1,331 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetests + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/focalboard/server/model" + storeservice "github.com/mattermost/focalboard/server/services/store" + "github.com/mattermost/focalboard/server/utils" +) + +func StoreTestCloudStore(t *testing.T, setup func(t *testing.T) (storeservice.Store, func())) { + t.Run("GetUsedCardsCount", func(t *testing.T) { + store, tearDown := setup(t) + defer tearDown() + testGetUsedCardsCount(t, store) + }) + t.Run("TestGetCardLimitTimestamp", func(t *testing.T) { + store, tearDown := setup(t) + defer tearDown() + testGetCardLimitTimestamp(t, store) + }) + t.Run("TestUpdateCardLimitTimestamp", func(t *testing.T) { + store, tearDown := setup(t) + defer tearDown() + testUpdateCardLimitTimestamp(t, store) + }) +} + +func testGetUsedCardsCount(t *testing.T, store storeservice.Store) { + userID := "user-id" + + t.Run("should return zero when no cards have been created", func(t *testing.T) { + count, err := store.GetUsedCardsCount() + require.NoError(t, err) + require.Zero(t, count) + }) + + t.Run("should correctly return the cards of all boards", func(t *testing.T) { + // two boards + for _, boardID := range []string{"board1", "board2"} { + boardType := model.BoardTypeOpen + if boardID == "board2" { + boardType = model.BoardTypePrivate + } + + board := &model.Board{ + ID: boardID, + TeamID: testTeamID, + Type: boardType, + } + + _, err := store.InsertBoard(board, userID) + require.NoError(t, err) + } + + // board 1 has three cards + for _, cardID := range []string{"card1", "card2", "card3"} { + card := model.Block{ + ID: cardID, + ParentID: "board1", + BoardID: "board1", + Type: model.TypeCard, + } + require.NoError(t, store.InsertBlock(&card, userID)) + } + + // board 2 has two cards + for _, cardID := range []string{"card4", "card5"} { + card := model.Block{ + ID: cardID, + ParentID: "board2", + BoardID: "board2", + Type: model.TypeCard, + } + require.NoError(t, store.InsertBlock(&card, userID)) + } + + count, err := store.GetUsedCardsCount() + require.NoError(t, err) + require.Equal(t, 5, count) + }) + + t.Run("should not take into account content blocks", func(t *testing.T) { + // we add a couple of content blocks + text := model.Block{ + ID: "text-id", + ParentID: "card1", + BoardID: "board1", + Type: model.TypeText, + } + require.NoError(t, store.InsertBlock(&text, userID)) + + view := model.Block{ + ID: "view-id", + ParentID: "board1", + BoardID: "board1", + Type: model.TypeView, + } + require.NoError(t, store.InsertBlock(&view, userID)) + + // and count should not change + count, err := store.GetUsedCardsCount() + require.NoError(t, err) + require.Equal(t, 5, count) + }) + + t.Run("should not take into account cards belonging to templates", func(t *testing.T) { + // we add a template with cards + templateID := "template-id" + boardTemplate := model.Block{ + ID: templateID, + BoardID: templateID, + Type: model.TypeBoard, + Fields: map[string]interface{}{ + "isTemplate": true, + }, + } + require.NoError(t, store.InsertBlock(&boardTemplate, userID)) + + for _, cardID := range []string{"card6", "card7", "card8"} { + card := model.Block{ + ID: cardID, + ParentID: templateID, + BoardID: templateID, + Type: model.TypeCard, + } + require.NoError(t, store.InsertBlock(&card, userID)) + } + + // and count should still be the same + count, err := store.GetUsedCardsCount() + require.NoError(t, err) + require.Equal(t, 5, count) + }) + + t.Run("should not take into account deleted cards", func(t *testing.T) { + // we create a ninth card on the first board + card9 := model.Block{ + ID: "card9", + ParentID: "board1", + BoardID: "board1", + Type: model.TypeCard, + DeleteAt: utils.GetMillis(), + } + require.NoError(t, store.InsertBlock(&card9, userID)) + + // and count should still be the same + count, err := store.GetUsedCardsCount() + require.NoError(t, err) + require.Equal(t, 5, count) + }) + + t.Run("should not take into account cards from deleted boards", func(t *testing.T) { + require.NoError(t, store.DeleteBoard("board2", "user-id")) + + count, err := store.GetUsedCardsCount() + require.NoError(t, err) + require.Equal(t, 3, count) + }) +} + +func testGetCardLimitTimestamp(t *testing.T, store storeservice.Store) { + t.Run("should return 0 if there is no entry in the database", func(t *testing.T) { + rawValue, err := store.GetSystemSetting(storeservice.CardLimitTimestampSystemKey) + require.NoError(t, err) + require.Equal(t, "", rawValue) + + cardLimitTimestamp, err := store.GetCardLimitTimestamp() + require.NoError(t, err) + require.Zero(t, cardLimitTimestamp) + }) + + t.Run("should return an int64 representation of the value", func(t *testing.T) { + require.NoError(t, store.SetSystemSetting(storeservice.CardLimitTimestampSystemKey, "1234")) + + cardLimitTimestamp, err := store.GetCardLimitTimestamp() + require.NoError(t, err) + require.Equal(t, int64(1234), cardLimitTimestamp) + }) + + t.Run("should return an invalid value error if the value is not a number", func(t *testing.T) { + require.NoError(t, store.SetSystemSetting(storeservice.CardLimitTimestampSystemKey, "abc")) + + cardLimitTimestamp, err := store.GetCardLimitTimestamp() + require.ErrorContains(t, err, "card limit value is invalid") + require.Zero(t, cardLimitTimestamp) + }) +} + +func testUpdateCardLimitTimestamp(t *testing.T, store storeservice.Store) { + userID := "user-id" + + // two boards + for _, boardID := range []string{"board1", "board2"} { + boardType := model.BoardTypeOpen + if boardID == "board2" { + boardType = model.BoardTypePrivate + } + + board := &model.Board{ + ID: boardID, + TeamID: testTeamID, + Type: boardType, + } + + _, err := store.InsertBoard(board, userID) + require.NoError(t, err) + } + + // board 1 has five cards + for _, cardID := range []string{"card1", "card2", "card3", "card4", "card5"} { + card := model.Block{ + ID: cardID, + ParentID: "board1", + BoardID: "board1", + Type: model.TypeCard, + } + require.NoError(t, store.InsertBlock(&card, userID)) + time.Sleep(10 * time.Millisecond) + } + + // board 2 has five cards + for _, cardID := range []string{"card6", "card7", "card8", "card9", "card10"} { + card := model.Block{ + ID: cardID, + ParentID: "board2", + BoardID: "board2", + Type: model.TypeCard, + } + require.NoError(t, store.InsertBlock(&card, userID)) + time.Sleep(10 * time.Millisecond) + } + + t.Run("should set the timestamp to zero if the card limit is zero", func(t *testing.T) { + cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(0) + require.NoError(t, err) + require.Zero(t, cardLimitTimestamp) + + cardLimitTimestampStr, err := store.GetSystemSetting(storeservice.CardLimitTimestampSystemKey) + require.NoError(t, err) + require.Equal(t, "0", cardLimitTimestampStr) + }) + + t.Run("should correctly modify the limit several times in a row", func(t *testing.T) { + cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(0) + require.NoError(t, err) + require.Zero(t, cardLimitTimestamp) + + cardLimitTimestamp, err = store.UpdateCardLimitTimestamp(10) + require.NoError(t, err) + require.NotZero(t, cardLimitTimestamp) + + cardLimitTimestampStr, err := store.GetSystemSetting(storeservice.CardLimitTimestampSystemKey) + require.NoError(t, err) + require.NotEqual(t, "0", cardLimitTimestampStr) + + cardLimitTimestamp, err = store.UpdateCardLimitTimestamp(0) + require.NoError(t, err) + require.Zero(t, cardLimitTimestamp) + + cardLimitTimestampStr, err = store.GetSystemSetting(storeservice.CardLimitTimestampSystemKey) + require.NoError(t, err) + require.Equal(t, "0", cardLimitTimestampStr) + }) + + t.Run("should set the correct timestamp", func(t *testing.T) { + t.Run("limit 10", func(t *testing.T) { + // we fetch the first block + card1, err := store.GetBlock("card1") + require.NoError(t, err) + + // and assert that if the limit is 10, the stored + // timestamp corresponds to the card's update_at + cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(10) + require.NoError(t, err) + require.Equal(t, card1.UpdateAt, cardLimitTimestamp) + }) + + t.Run("limit 5", func(t *testing.T) { + // if the limit is 5, the timestamp should be the one from + // the sixth card (the first five are older and out of the + card6, err := store.GetBlock("card6") + require.NoError(t, err) + + cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(5) + require.NoError(t, err) + require.Equal(t, card6.UpdateAt, cardLimitTimestamp) + }) + + t.Run("limit should be zero if we have less cards than the limit", func(t *testing.T) { + cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(100) + require.NoError(t, err) + require.Zero(t, cardLimitTimestamp) + }) + + t.Run("we update the first inserted card and assert that with limit 1 that's the limit that is set", func(t *testing.T) { + time.Sleep(10 * time.Millisecond) + card1, err := store.GetBlock("card1") + require.NoError(t, err) + + card1.Title = "New title" + require.NoError(t, store.InsertBlock(card1, userID)) + + newCard1, err := store.GetBlock("card1") + require.NoError(t, err) + + cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(1) + require.NoError(t, err) + require.Equal(t, newCard1.UpdateAt, cardLimitTimestamp) + }) + + t.Run("limit should stop applying if we remove the last card", func(t *testing.T) { + initialCardLimitTimestamp, err := store.GetCardLimitTimestamp() + require.NoError(t, err) + require.NotZero(t, initialCardLimitTimestamp) + + time.Sleep(10 * time.Millisecond) + require.NoError(t, store.DeleteBlock("card1", userID)) + + cardLimitTimestamp, err := store.UpdateCardLimitTimestamp(10) + require.NoError(t, err) + require.Zero(t, cardLimitTimestamp) + }) + }) +} diff --git a/server/utils/utils.go b/server/utils/utils.go index 24329126b..995c2cc87 100644 --- a/server/utils/utils.go +++ b/server/utils/utils.go @@ -5,7 +5,7 @@ import ( "reflect" "time" - mm_model "github.com/mattermost/mattermost-server/v6/model" + mmModel "github.com/mattermost/mattermost-server/v6/model" ) type IDType byte @@ -27,22 +27,22 @@ const ( // with the padding stripped off, and a one character alpha prefix indicating the // type of entity or a `7` if unknown type. func NewID(idType IDType) string { - return string(idType) + mm_model.NewId() + return string(idType) + mmModel.NewId() } // GetMillis is a convenience method to get milliseconds since epoch. func GetMillis() int64 { - return mm_model.GetMillis() + return mmModel.GetMillis() } // GetMillisForTime is a convenience method to get milliseconds since epoch for provided Time. func GetMillisForTime(thisTime time.Time) int64 { - return mm_model.GetMillisForTime(thisTime) + return mmModel.GetMillisForTime(thisTime) } // GetTimeForMillis is a convenience method to get time.Time for milliseconds since epoch. func GetTimeForMillis(millis int64) time.Time { - return mm_model.GetTimeForMillis(millis) + return mmModel.GetTimeForMillis(millis) } // SecondsToMillis is a convenience method to convert seconds to milliseconds. @@ -95,3 +95,10 @@ func Intersection(x ...[]interface{}) []interface{} { return result } + +func IsCloudLicense(license *mmModel.License) bool { + return license != nil && + license.Features != nil && + license.Features.Cloud != nil && + *license.Features.Cloud +} diff --git a/server/ws/adapter.go b/server/ws/adapter.go index 201aa1758..9357b021b 100644 --- a/server/ws/adapter.go +++ b/server/ws/adapter.go @@ -6,19 +6,20 @@ import ( ) const ( - websocketActionAuth = "AUTH" - websocketActionSubscribeTeam = "SUBSCRIBE_TEAM" - websocketActionUnsubscribeTeam = "UNSUBSCRIBE_TEAM" - websocketActionSubscribeBlocks = "SUBSCRIBE_BLOCKS" - websocketActionUnsubscribeBlocks = "UNSUBSCRIBE_BLOCKS" - websocketActionUpdateBoard = "UPDATE_BOARD" - websocketActionUpdateMember = "UPDATE_MEMBER" - websocketActionDeleteMember = "DELETE_MEMBER" - websocketActionUpdateBlock = "UPDATE_BLOCK" - websocketActionUpdateConfig = "UPDATE_CLIENT_CONFIG" - websocketActionUpdateCategory = "UPDATE_CATEGORY" - websocketActionUpdateCategoryBoard = "UPDATE_BOARD_CATEGORY" - websocketActionUpdateSubscription = "UPDATE_SUBSCRIPTION" + websocketActionAuth = "AUTH" + websocketActionSubscribeTeam = "SUBSCRIBE_TEAM" + websocketActionUnsubscribeTeam = "UNSUBSCRIBE_TEAM" + websocketActionSubscribeBlocks = "SUBSCRIBE_BLOCKS" + websocketActionUnsubscribeBlocks = "UNSUBSCRIBE_BLOCKS" + websocketActionUpdateBoard = "UPDATE_BOARD" + websocketActionUpdateMember = "UPDATE_MEMBER" + websocketActionDeleteMember = "DELETE_MEMBER" + websocketActionUpdateBlock = "UPDATE_BLOCK" + websocketActionUpdateConfig = "UPDATE_CLIENT_CONFIG" + websocketActionUpdateCategory = "UPDATE_CATEGORY" + websocketActionUpdateCategoryBoard = "UPDATE_BOARD_CATEGORY" + websocketActionUpdateSubscription = "UPDATE_SUBSCRIPTION" + websocketActionUpdateCardLimitTimestamp = "UPDATE_CARD_LIMIT_TIMESTAMP" ) type Store interface { @@ -36,5 +37,6 @@ type Adapter interface { BroadcastConfigChange(clientConfig model.ClientConfig) BroadcastCategoryChange(category model.Category) BroadcastCategoryBoardChange(teamID, userID string, blockCategory model.BoardCategoryWebsocketData) + BroadcastCardLimitTimestampChange(cardLimitTimestamp int64) BroadcastSubscriptionChange(teamID string, subscription *model.Subscription) } diff --git a/server/ws/common.go b/server/ws/common.go index 3d0f92c93..c363100a9 100644 --- a/server/ws/common.go +++ b/server/ws/common.go @@ -39,6 +39,18 @@ type UpdateSubscription struct { Subscription *model.Subscription `json:"subscription"` } +// UpdateClientConfig is sent on block updates. +type UpdateClientConfig struct { + Action string `json:"action"` + ClientConfig model.ClientConfig `json:"clientconfig"` +} + +// UpdateClientConfig is sent on block updates. +type UpdateCardLimitTimestamp struct { + Action string `json:"action"` + Timestamp int64 `json:"timestamp"` +} + // WebsocketCommand is an incoming command from the client. type WebsocketCommand struct { Action string `json:"action"` diff --git a/server/ws/plugin_adapter.go b/server/ws/plugin_adapter.go index 74a25d033..b7e44c085 100644 --- a/server/ws/plugin_adapter.go +++ b/server/ws/plugin_adapter.go @@ -30,6 +30,7 @@ type PluginAdapterInterface interface { BroadcastBlockChange(teamID string, block model.Block) BroadcastBlockDelete(teamID, blockID, parentID string) BroadcastSubscriptionChange(teamID string, subscription *model.Subscription) + BroadcastCardLimitTimestampChange(cardLimitTimestamp int64) HandleClusterEvent(ev mmModel.PluginClusterEvent) } @@ -593,3 +594,16 @@ func (pa *PluginAdapter) BroadcastSubscriptionChange(teamID string, subscription pa.sendTeamMessage(websocketActionUpdateSubscription, teamID, utils.StructToMap(message)) } + +func (pa *PluginAdapter) BroadcastCardLimitTimestampChange(cardLimitTimestamp int64) { + pa.logger.Debug("BroadcastCardLimitTimestampChange", + mlog.Int64("cardLimitTimestamp", cardLimitTimestamp), + ) + + message := UpdateCardLimitTimestamp{ + Action: websocketActionUpdateCardLimitTimestamp, + Timestamp: cardLimitTimestamp, + } + + pa.sendMessageToAll(websocketActionUpdateCardLimitTimestamp, utils.StructToMap(message)) +} diff --git a/server/ws/server.go b/server/ws/server.go index d2e867caa..09dd15bd0 100644 --- a/server/ws/server.go +++ b/server/ws/server.go @@ -55,12 +55,6 @@ type Server struct { store Store } -// UpdateClientConfig is sent on block updates. -type UpdateClientConfig struct { - Action string `json:"action"` - ClientConfig model.ClientConfig `json:"clientconfig"` -} - type websocketSession struct { conn *websocket.Conn userID string @@ -754,3 +748,7 @@ func (ws *Server) BroadcastMemberDelete(teamID, boardID, userID string) { func (ws *Server) BroadcastSubscriptionChange(workspaceID string, subscription *model.Subscription) { // not implemented for standalone server. } + +func (ws *Server) BroadcastCardLimitTimestampChange(cardLimitTimestamp int64) { + // not implemented for standalone server. +} diff --git a/webapp/src/hooks/cardListener.tsx b/webapp/src/hooks/cardListener.tsx index 24d10c9c7..072e9bd60 100644 --- a/webapp/src/hooks/cardListener.tsx +++ b/webapp/src/hooks/cardListener.tsx @@ -7,7 +7,6 @@ import wsClient, {WSClient} from '../wsclient' export default function useCardListener(onChange: (blocks: Block[]) => void, onReconnect: () => void): void { useEffect(() => { - // ToDo: does this onChange need boards as well?? const onChangeHandler = (_: WSClient, blocks: Block[]) => onChange(blocks) wsClient.addOnChange(onChangeHandler, 'block') wsClient.addOnReconnect(onReconnect) diff --git a/webapp/src/octoClient.ts b/webapp/src/octoClient.ts index df146f4fe..410a5baa2 100644 --- a/webapp/src/octoClient.ts +++ b/webapp/src/octoClient.ts @@ -141,7 +141,6 @@ class OctoClient { } } - // ToDo: document private teamPath(teamId?: string): string { let teamIdToUse = teamId if (!teamId) { @@ -618,14 +617,6 @@ class OctoClient { return this.getBoardsWithPath(path) } - // Boards - // ToDo: . - // - goal? make the interface show boards & blocks for boards - // - teams (maybe current team)? boards, members, user roles in the store, whatever that is - // - selectors for boards, current team, board members - // - ops to add/delete a board, add/delete board members, change roles? . - // - WS definition and implementation - async getBoards(): Promise { const path = this.teamPath() + '/boards' return this.getBoardsWithPath(path) diff --git a/webapp/src/octoUtils.test.ts b/webapp/src/octoUtils.test.ts index 0696b9468..6341280e9 100644 --- a/webapp/src/octoUtils.test.ts +++ b/webapp/src/octoUtils.test.ts @@ -6,30 +6,6 @@ import {OctoUtils} from './octoUtils' import {TestBlockFactory} from './test/testBlockFactory' -// ToDo: we need a way to duplicate the board first creating a new -// board and then dupliating and inserting all its blocks -// test('duplicateBlockTree: Board', async () => { -// const [blocks, board] = createBoardTree() -// -// const [newBlocks, newBoard, idMap] = OctoUtils.duplicateBlockTree(blocks, board.id) -// -// expect(newBlocks.length).toBe(blocks.length) -// expect(newSourceBlock.id).not.toBe(sourceBlock) -// expect(newSourceBlock.type).toBe(sourceBlock.type) -// -// // When duplicating a root block, the boardId should be re-mapped -// expect(newSourceBlock.boardId).not.toBe(sourceBlock.boardId) -// expect(idMap[sourceBlock.id]).toBe(newSourceBlock.id) -// -// for (const newBlock of newBlocks) { -// expect(newBlock.boardId).toBe(newSourceBlock.id) -// } -// -// for (const textBlock of newBlocks.filter((o) => o.type === 'card')) { -// expect(textBlock.parentId).toBe(newSourceBlock.id) -// } -// }) - test('duplicateBlockTree: Card', async () => { const [blocks, sourceBlock] = createCardTree() @@ -52,27 +28,6 @@ test('duplicateBlockTree: Card', async () => { } }) -// function createBoardTree(): [Block[], Board] { -// const blocks: Block[] = [] - -// const board = TestBlockFactory.createBoard() -// board.id = 'board1' - -// for (let i = 0; i < 5; i++) { -// const card = TestBlockFactory.createCard(board) -// card.id = `card${i}` -// blocks.push(card) - -// for (let j = 0; j < 3; j++) { -// const textBlock = TestBlockFactory.createText(card) -// textBlock.id = `text${j}` -// blocks.push(textBlock) -// } -// } - -// return [blocks, board] -// } - function createCardTree(): [Block[], Block] { const blocks: Block[] = [] diff --git a/webapp/src/pages/boardPage/websocketConnection.tsx b/webapp/src/pages/boardPage/websocketConnection.tsx index 94171948f..b203d9f3d 100644 --- a/webapp/src/pages/boardPage/websocketConnection.tsx +++ b/webapp/src/pages/boardPage/websocketConnection.tsx @@ -56,10 +56,6 @@ const WebsocketConnection = (props: Props) => { } const incrementalBlockUpdate = (_: WSClient, blocks: Block[]) => { - // ToDo: update this - // - create a selector to get user boards - // - replace the teamId check of blocks by a "is in my boards" check - /* const teamBlocks = blocks.filter((b: Block) => b.teamId === '0' || b.boardId in userBoardIds) */ const teamBlocks = blocks batch(() => { diff --git a/webapp/src/store/globalTemplates.ts b/webapp/src/store/globalTemplates.ts index 9352aa2e5..4507e12d6 100644 --- a/webapp/src/store/globalTemplates.ts +++ b/webapp/src/store/globalTemplates.ts @@ -10,8 +10,6 @@ import {Constants} from "../constants" import {RootState} from './index' -// ToDo: move this to team templates or simply templates - export const fetchGlobalTemplates = createAsyncThunk( 'globalTemplates/fetch', async () => { diff --git a/webapp/src/wsclient.ts b/webapp/src/wsclient.ts index 2c83a976a..e92c3571c 100644 --- a/webapp/src/wsclient.ts +++ b/webapp/src/wsclient.ts @@ -27,6 +27,7 @@ export type WSMessage = { error?: string teamId?: string member?: BoardMember + timestamp?: number } export const ACTION_UPDATE_BOARD = 'UPDATE_BOARD' @@ -42,6 +43,7 @@ export const ACTION_UPDATE_CLIENT_CONFIG = 'UPDATE_CLIENT_CONFIG' export const ACTION_UPDATE_CATEGORY = 'UPDATE_CATEGORY' export const ACTION_UPDATE_BOARD_CATEGORY = 'UPDATE_BOARD_CATEGORY' export const ACTION_UPDATE_SUBSCRIPTION = 'UPDATE_SUBSCRIPTION' +export const ACTION_UPDATE_CARD_LIMIT_TIMESTAMP = 'UPDATE_CARD_LIMIT_TIMESTAMP' type WSSubscriptionMsg = { action?: string @@ -74,6 +76,7 @@ type OnReconnectHandler = (client: WSClient) => void type OnStateChangeHandler = (client: WSClient, state: 'init' | 'open' | 'close') => void type OnErrorHandler = (client: WSClient, e: Event) => void type OnConfigChangeHandler = (client: WSClient, clientConfig: ClientConfig) => void +type OnCardLimitTimestampChangeHandler = (client: WSClient, timestamp: number) => void type FollowChangeHandler = (client: WSClient, subscription: Subscription) => void export type ChangeHandlerType = 'block' | 'category' | 'blockCategories' | 'board' | 'boardMembers' @@ -110,6 +113,7 @@ class WSClient { onChange: ChangeHandlers = {Block: [], Category: [], BoardCategory: [], Board: [], BoardMember: []} onError: OnErrorHandler[] = [] onConfigChange: OnConfigChangeHandler[] = [] + onCardLimitTimestampChange: OnCardLimitTimestampChangeHandler[] = [] onFollowBlock: FollowChangeHandler = () => {} onUnfollowBlock: FollowChangeHandler = () => {} private notificationDelay = 100 @@ -254,6 +258,17 @@ class WSClient { } } + addOnCardLimitTimestampChange(handler: OnCardLimitTimestampChangeHandler): void { + this.onCardLimitTimestampChange.push(handler) + } + + removeOnCardLimitTimestampChange(handler: OnCardLimitTimestampChangeHandler): void { + const index = this.onCardLimitTimestampChange.indexOf(handler) + if (index !== -1) { + this.onCardLimitTimestampChange.splice(index, 1) + } + } + open(): void { if (this.client !== null) { // configure the Mattermost websocket client callbacks @@ -431,6 +446,12 @@ class WSClient { } } + updateCardLimitTimestampHandler(action: {action: string, timestamp: number}): void { + for (const handler of this.onCardLimitTimestampChange) { + handler(this, action.timestamp) + } + } + updateSubscriptionHandler(message: WSSubscriptionMsg): void { Utils.log('updateSubscriptionHandler: ' + message.action + '; blockId=' + message.subscription?.blockId)