From f00b5c9e61e1d96caa5ecc262a6a28d301237a0d Mon Sep 17 00:00:00 2001 From: Shivashis Padhi Date: Mon, 8 Aug 2022 11:42:02 +0530 Subject: [PATCH] [MM-43781] Feature: boards insights (#3005) * Add boilerplate functions and handlers for boards insights * Fix function signatures to add 'duration' parameter, fix where clauses in db queries * Fix where clause to include boards of which userId in parameter is a member * Modify queries to work with sqlite, postgres, mysql * Integration tests, and results of make generate * Lint Fixes * Add icons to board insights * Lint fixes * Format insights queries without squirrel to fix parameterization issues * Add tests for sqlstore utility functions * Improve team insights tests by creating 2 boards * Refactor endpoints/app to adhere to developments in 7.0 release * Refactor queries to use squirrel * Lint fixes * Fix client, integration tests * Remove old integration tests * Add storetests, refactor functions to handle authorized board_ids * Make queries compatible with mysql, sqlite * Add app tests * Fix lint errors * Revert makefile changes, fix docstring in api * Lint fixes and doc correction suggested by @wiggin77 * Fix mock store call count error * adding client code * Make the following changes - use serviceAPI to get user.Timezone - rename licenseAndGuestUserCheck to insightPermissionGate, and handle returned error better - validate page, perPage parameters aren't < 0 * Lint fix Co-authored-by: Mattermod Co-authored-by: Benjamin Cooke --- mattermost-plugin/webapp/src/index.tsx | 15 ++ .../src/types/mattermost-webapp/index.d.ts | 1 + server/api/api.go | 214 ++++++++++++++++++ server/app/insights.go | 78 +++++++ server/app/insights_test.go | 89 ++++++++ server/client/client.go | 30 +++ server/model/board_insights.go | 62 +++++ .../mattermostauthlayer.go | 9 + server/services/store/mockstore/mockstore.go | 45 ++++ .../services/store/sqlstore/board_insights.go | 147 ++++++++++++ .../services/store/sqlstore/public_methods.go | 15 ++ server/services/store/sqlstore/sqlstore.go | 24 ++ .../services/store/sqlstore/sqlstore_test.go | 37 +++ server/services/store/sqlstore/user.go | 4 + server/services/store/store.go | 5 + .../store/storetests/board_insights.go | 99 ++++++++ webapp/src/insights/index.ts | 16 ++ webapp/src/octoClient.ts | 22 ++ 18 files changed, 912 insertions(+) create mode 100644 server/app/insights.go create mode 100644 server/app/insights_test.go create mode 100644 server/model/board_insights.go create mode 100644 server/services/store/sqlstore/board_insights.go create mode 100644 server/services/store/storetests/board_insights.go create mode 100644 webapp/src/insights/index.ts diff --git a/mattermost-plugin/webapp/src/index.tsx b/mattermost-plugin/webapp/src/index.tsx index 86f1a35f1..c92995ebc 100644 --- a/mattermost-plugin/webapp/src/index.tsx +++ b/mattermost-plugin/webapp/src/index.tsx @@ -319,6 +319,21 @@ export default class Plugin { ), false ) + + // Insights handler + if (this.registry?.registerInsightsHandler) { + this.registry?.registerInsightsHandler(async (timeRange: string, page: number, perPage: number, teamId: string, insightType: string) => { + if (insightType === 'MY') { + const data = await octoClient.getMyTopBoards(timeRange, page, perPage, teamId) + + return data + } + + const data = await octoClient.getTeamTopBoards(timeRange, page, perPage, teamId) + + return data + }) + } } this.boardSelectorId = this.registry.registerRootComponent((props: {webSocketClient: MMWebSocketClient}) => ( diff --git a/mattermost-plugin/webapp/src/types/mattermost-webapp/index.d.ts b/mattermost-plugin/webapp/src/types/mattermost-webapp/index.d.ts index 6e6fd24fa..73b4082cb 100644 --- a/mattermost-plugin/webapp/src/types/mattermost-webapp/index.d.ts +++ b/mattermost-plugin/webapp/src/types/mattermost-webapp/index.d.ts @@ -17,6 +17,7 @@ export interface PluginRegistry { registerAppBarComponent(iconURL: string, action: (channel: Channel, member: ChannelMembership) => void, tooltipText: React.ReactNode) registerRightHandSidebarComponent(component: React.ElementType, title: React.Element) registerRootComponent(component: React.ElementType) + registerInsightsHandler(handler: (timeRange: string, page: number, perPage: number, teamId: string, insightType: string) => void) // Add more if needed from https://developers.mattermost.com/extend/plugins/webapp/reference } diff --git a/server/api/api.go b/server/api/api.go index cbbccbd98..b1a13e825 100644 --- a/server/api/api.go +++ b/server/api/api.go @@ -181,6 +181,10 @@ func (a *API) RegisterRoutes(r *mux.Router) { // System APIs r.HandleFunc("/hello", a.handleHello).Methods("GET") + + // Insights APIs + apiv2.HandleFunc("/teams/{teamID}/boards/insights", a.sessionRequired(a.handleTeamBoardsInsights)).Methods("GET") + apiv2.HandleFunc("/users/me/boards/insights", a.sessionRequired(a.handleUserBoardsInsights)).Methods("GET") } func (a *API) RegisterAdminRoutes(r *mux.Router) { @@ -2179,6 +2183,216 @@ func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) { auditRec.AddMeta("fileID", fileID) auditRec.Success() } +func (a *API) handleTeamBoardsInsights(w http.ResponseWriter, r *http.Request) { + // swagger:operation GET /teams/{teamID}/boards/insights handleTeamBoardsInsights + // + // Returns team boards insights + // + // --- + // produces: + // - application/json + // parameters: + // - name: teamID + // in: path + // description: Team ID + // required: true + // type: string + // - name: time_range + // in: query + // description: duration of data to calculate insights for + // required: true + // type: string + // - name: page + // in: query + // description: page offset for top boards + // required: true + // type: string + // - name: per_page + // in: query + // description: limit for boards in a page. + // required: true + // type: string + // security: + // - BearerAuth: [] + // responses: + // '200': + // description: success + // schema: + // type: array + // items: + // "$ref": "#/definitions/BoardInsight" + // default: + // description: internal error + // schema: + // "$ref": "#/definitions/ErrorResponse" + + vars := mux.Vars(r) + teamID := vars["teamID"] + userID := getUserID(r) + query := r.URL.Query() + timeRange := query.Get("time_range") + + if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { + a.errorResponse(w, r.URL.Path, http.StatusForbidden, "Access denied to team", PermissionError{"access denied to team"}) + return + } + + auditRec := a.makeAuditRecord(r, "getTeamBoardsInsights", audit.Fail) + defer a.audit.LogRecord(audit.LevelRead, auditRec) + + page, err := strconv.Atoi(query.Get("page")) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "error converting page parameter to integer", err) + return + } + if page < 0 { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "Invalid page parameter", nil) + } + + perPage, err := strconv.Atoi(query.Get("per_page")) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "error converting per_page parameter to integer", err) + return + } + if perPage < 0 { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "Invalid page parameter", nil) + } + + userTimezone, aErr := a.app.GetUserTimezone(userID) + if aErr != nil { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "Error getting time zone of user", aErr) + return + } + userLocation, _ := time.LoadLocation(userTimezone) + if userLocation == nil { + userLocation = time.Now().UTC().Location() + } + // get unix time for duration + startTime := mmModel.StartOfDayForTimeRange(timeRange, userLocation) + boardsInsights, err := a.app.GetTeamBoardsInsights(userID, teamID, &mmModel.InsightsOpts{ + StartUnixMilli: mmModel.GetMillisForTime(*startTime), + Page: page, + PerPage: perPage, + }) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "time_range="+timeRange, err) + return + } + + data, err := json.Marshal(boardsInsights) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) + return + } + + jsonBytesResponse(w, http.StatusOK, data) + + auditRec.AddMeta("teamBoardsInsightCount", len(boardsInsights.Items)) + auditRec.Success() +} + +func (a *API) handleUserBoardsInsights(w http.ResponseWriter, r *http.Request) { + // swagger:operation GET /users/me/boards/insights getUserBoardsInsights + // + // Returns user boards insights + // + // --- + // produces: + // - application/json + // parameters: + // - name: teamID + // in: path + // description: Team ID + // required: true + // type: string + // - name: time_range + // in: query + // description: duration of data to calculate insights for + // required: true + // type: string + // - name: page + // in: query + // description: page offset for top boards + // required: true + // type: string + // - name: per_page + // in: query + // description: limit for boards in a page. + // required: true + // type: string + // security: + // - BearerAuth: [] + // responses: + // '200': + // description: success + // schema: + // type: array + // items: + // "$ref": "#/definitions/BoardInsight" + // default: + // description: internal error + // schema: + // "$ref": "#/definitions/ErrorResponse" + userID := getUserID(r) + query := r.URL.Query() + teamID := query.Get("team_id") + timeRange := query.Get("time_range") + + if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) { + a.errorResponse(w, r.URL.Path, http.StatusForbidden, "Access denied to team", PermissionError{"access denied to team"}) + return + } + + auditRec := a.makeAuditRecord(r, "getUserBoardsInsights", audit.Fail) + defer a.audit.LogRecord(audit.LevelRead, auditRec) + page, err := strconv.Atoi(query.Get("page")) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "error converting page parameter to integer", err) + return + } + + if page < 0 { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "Invalid page parameter", nil) + } + perPage, err := strconv.Atoi(query.Get("per_page")) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "error converting per_page parameter to integer", err) + return + } + + if perPage < 0 { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "Invalid page parameter", nil) + } + userTimezone, aErr := a.app.GetUserTimezone(userID) + if aErr != nil { + a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "Error getting time zone of user", aErr) + return + } + userLocation, _ := time.LoadLocation(userTimezone) + if userLocation == nil { + userLocation = time.Now().UTC().Location() + } + // get unix time for duration + startTime := mmModel.StartOfDayForTimeRange(timeRange, userLocation) + boardsInsights, err := a.app.GetUserBoardsInsights(userID, teamID, &mmModel.InsightsOpts{ + StartUnixMilli: mmModel.GetMillisForTime(*startTime), + Page: page, + PerPage: perPage, + }) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "time_range="+timeRange, err) + return + } + data, err := json.Marshal(boardsInsights) + if err != nil { + a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err) + return + } + jsonBytesResponse(w, http.StatusOK, data) + + auditRec.AddMeta("userBoardInsightCount", len(boardsInsights.Items)) + auditRec.Success() +} func (a *API) handleGetTeamUsers(w http.ResponseWriter, r *http.Request) { // swagger:operation GET /teams/{teamID}/users getTeamUsers diff --git a/server/app/insights.go b/server/app/insights.go new file mode 100644 index 000000000..bdd62400d --- /dev/null +++ b/server/app/insights.go @@ -0,0 +1,78 @@ +package app + +import ( + "github.com/mattermost/focalboard/server/model" + mmModel "github.com/mattermost/mattermost-server/v6/model" + "github.com/pkg/errors" +) + +func (a *App) GetTeamBoardsInsights(userID string, teamID string, opts *mmModel.InsightsOpts) (*model.BoardInsightsList, error) { + // check if server is properly licensed, and user is not a guest + userPermitted, err := insightPermissionGate(a, userID) + if err != nil { + return nil, err + } + if !userPermitted { + return nil, errors.New("User isn't authorized to access insights.") + } + boardIDs, err := getUserBoards(userID, teamID, a) + if err != nil { + return nil, err + } + return a.store.GetTeamBoardsInsights(teamID, userID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage, boardIDs) +} + +func (a *App) GetUserBoardsInsights(userID string, teamID string, opts *mmModel.InsightsOpts) (*model.BoardInsightsList, error) { + // check if server is properly licensed, and user is not a guest + userPermitted, err := insightPermissionGate(a, userID) + if err != nil { + return nil, err + } + if !userPermitted { + return nil, errors.New("User isn't authorized to access insights.") + } + boardIDs, err := getUserBoards(userID, teamID, a) + if err != nil { + return nil, err + } + return a.store.GetUserBoardsInsights(teamID, userID, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage, boardIDs) +} + +func insightPermissionGate(a *App, userID string) (bool, error) { + licenseError := errors.New("invalid license/authorization to use insights API") + guestError := errors.New("guests aren't authorized to use insights API") + lic := a.store.GetLicense() + if lic == nil { + a.logger.Debug("Deployment doesn't have a license") + return false, licenseError + } + user, err := a.store.GetUserByID(userID) + if err != nil { + return false, err + } + if lic.SkuShortName != mmModel.LicenseShortSkuProfessional && lic.SkuShortName != mmModel.LicenseShortSkuEnterprise { + return false, licenseError + } + if user.IsGuest { + return false, guestError + } + return true, nil +} + +func (a *App) GetUserTimezone(userID string) (string, error) { + return a.store.GetUserTimezone(userID) +} + +func getUserBoards(userID string, teamID string, a *App) ([]string, error) { + // get boards accessible by user and filter boardIDs + boards, err := a.store.GetBoardsForUserAndTeam(userID, teamID) + if err != nil { + return nil, errors.New("error getting boards for user") + } + boardIDs := make([]string, 0, len(boards)) + + for _, board := range boards { + boardIDs = append(boardIDs, board.ID) + } + return boardIDs, nil +} diff --git a/server/app/insights_test.go b/server/app/insights_test.go new file mode 100644 index 000000000..95f9bc7fe --- /dev/null +++ b/server/app/insights_test.go @@ -0,0 +1,89 @@ +package app + +import ( + "testing" + + "github.com/mattermost/focalboard/server/model" + mmModel "github.com/mattermost/mattermost-server/v6/model" + "github.com/stretchr/testify/require" +) + +var mockInsightsBoards = []*model.Board{ + { + ID: "mock-user-workspace-id", + Title: "MockUserWorkspace", + }, +} + +var mockTeamInsights = []*model.BoardInsight{ + { + BoardID: "board-id-1", + }, + { + BoardID: "board-id-2", + }, +} + +var mockTeamInsightsList = &model.BoardInsightsList{ + InsightsListData: mmModel.InsightsListData{HasNext: false}, + Items: mockTeamInsights, +} + +type insightError struct { + msg string +} + +func (ie insightError) Error() string { + return ie.msg +} + +func TestGetTeamAndUserBoardsInsights(t *testing.T) { + th, tearDown := SetupTestHelper(t) + defer tearDown() + + t.Run("success query", func(t *testing.T) { + fakeLicense := &mmModel.License{Features: &mmModel.Features{}, SkuShortName: mmModel.LicenseShortSkuEnterprise} + th.Store.EXPECT().GetLicense().Return(fakeLicense).AnyTimes() + fakeUser := &model.User{ + ID: "user-id", + IsGuest: false, + } + th.Store.EXPECT().GetUserByID("user-id").Return(fakeUser, nil).AnyTimes() + th.Store.EXPECT().GetBoardsForUserAndTeam("user-id", "team-id").Return(mockInsightsBoards, nil).AnyTimes() + th.Store.EXPECT(). + GetTeamBoardsInsights("team-id", "user-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}). + Return(mockTeamInsightsList, nil) + results, err := th.App.GetTeamBoardsInsights("user-id", "team-id", &mmModel.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10}) + require.NoError(t, err) + require.Len(t, results.Items, 2) + th.Store.EXPECT(). + GetUserBoardsInsights("team-id", "user-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}). + Return(mockTeamInsightsList, nil) + results, err = th.App.GetUserBoardsInsights("user-id", "team-id", &mmModel.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10}) + require.NoError(t, err) + require.Len(t, results.Items, 2) + }) + + t.Run("fail query", func(t *testing.T) { + fakeLicense := &mmModel.License{Features: &mmModel.Features{}, SkuShortName: mmModel.LicenseShortSkuEnterprise} + th.Store.EXPECT().GetLicense().Return(fakeLicense).AnyTimes() + fakeUser := &model.User{ + ID: "user-id", + IsGuest: false, + } + th.Store.EXPECT().GetUserByID("user-id").Return(fakeUser, nil).AnyTimes() + th.Store.EXPECT().GetBoardsForUserAndTeam("user-id", "team-id").Return(mockInsightsBoards, nil).AnyTimes() + th.Store.EXPECT(). + GetTeamBoardsInsights("team-id", "user-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}). + Return(nil, insightError{"board-insight-error"}) + _, err := th.App.GetTeamBoardsInsights("user-id", "team-id", &mmModel.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10}) + require.Error(t, err) + require.ErrorIs(t, err, insightError{"board-insight-error"}) + th.Store.EXPECT(). + GetUserBoardsInsights("team-id", "user-id", int64(0), 0, 10, []string{"mock-user-workspace-id"}). + Return(nil, insightError{"board-insight-error"}) + _, err = th.App.GetUserBoardsInsights("user-id", "team-id", &mmModel.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10}) + require.Error(t, err) + require.ErrorIs(t, err, insightError{"board-insight-error"}) + }) +} diff --git a/server/client/client.go b/server/client/client.go index f7f1a45f5..10d21476b 100644 --- a/server/client/client.go +++ b/server/client/client.go @@ -206,6 +206,36 @@ func (c *Client) GetTeam(teamID string) (*model.Team, *Response) { return model.TeamFromJSON(r.Body), BuildResponse(r) } +func (c *Client) GetTeamBoardsInsights(teamID string, userID string, timeRange string, page int, perPage int) (*model.BoardInsightsList, *Response) { + query := fmt.Sprintf("?time_range=%v&page=%v&per_page=%v", timeRange, page, perPage) + r, err := c.DoAPIGet(c.GetTeamRoute(teamID)+"/boards/insights"+query, "") + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + + var boardInsightsList *model.BoardInsightsList + if jsonErr := json.NewDecoder(r.Body).Decode(&boardInsightsList); jsonErr != nil { + return nil, BuildErrorResponse(r, jsonErr) + } + return boardInsightsList, BuildResponse(r) +} + +func (c *Client) GetUserBoardsInsights(teamID string, userID string, timeRange string, page int, perPage int) (*model.BoardInsightsList, *Response) { + query := fmt.Sprintf("?time_range=%v&page=%v&per_page=%v&team_id=%v", timeRange, page, perPage, teamID) + r, err := c.DoAPIGet(c.GetMeRoute()+"/boards/insights"+query, "") + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + + var boardInsightsList *model.BoardInsightsList + if jsonErr := json.NewDecoder(r.Body).Decode(&boardInsightsList); jsonErr != nil { + return nil, BuildErrorResponse(r, jsonErr) + } + return boardInsightsList, BuildResponse(r) +} + func (c *Client) GetBlocksForBoard(boardID string) ([]model.Block, *Response) { r, err := c.DoAPIGet(c.GetBlocksRoute(boardID), "") if err != nil { diff --git a/server/model/board_insights.go b/server/model/board_insights.go new file mode 100644 index 000000000..d4beaf04d --- /dev/null +++ b/server/model/board_insights.go @@ -0,0 +1,62 @@ +package model + +import ( + "encoding/json" + "io" + + mmModel "github.com/mattermost/mattermost-server/v6/model" +) + +// BoardInsightsList is a response type with pagination support. +type BoardInsightsList struct { + mmModel.InsightsListData + Items []*BoardInsight `json:"items"` +} + +// BoardInsight gives insight into activities in a Board +// swagger:model +type BoardInsight struct { + // ID of the board + // required: true + BoardID string `json:"boardID"` + + // icon of the board + // required: false + Icon string `json:"icon"` + + // Title of the board + // required: false + Title string `json:"title"` + + // Metric of how active the board is + // required: true + ActivityCount string `json:"activityCount"` + + // IDs of users active on the board + // required: true + ActiveUsers string `json:"activeUsers"` + + // ID of user who created the board + // required: true + CreatedBy string `json:"createdBy"` +} + +func BoardInsightsFromJSON(data io.Reader) []BoardInsight { + var boardInsights []BoardInsight + _ = json.NewDecoder(data).Decode(&boardInsights) + return boardInsights +} + +// GetTopBoardInsightsListWithPagination adds a rank to each item in the given list of BoardInsight and checks if there is +// another page that can be fetched based on the given limit and offset. The given list of BoardInsight is assumed to be +// sorted by ActivityCount(score). Returns a BoardInsightsList. +func GetTopBoardInsightsListWithPagination(boards []*BoardInsight, limit int) *BoardInsightsList { + // Add pagination support + var hasNext bool + if limit != 0 && len(boards) == limit+1 { + hasNext = true + boards = boards[:len(boards)-1] + } + + return &BoardInsightsList{InsightsListData: mmModel.InsightsListData{HasNext: hasNext}, Items: boards} +} diff --git a/server/services/store/mattermostauthlayer/mattermostauthlayer.go b/server/services/store/mattermostauthlayer/mattermostauthlayer.go index 926db85bb..ab94f4b9d 100644 --- a/server/services/store/mattermostauthlayer/mattermostauthlayer.go +++ b/server/services/store/mattermostauthlayer/mattermostauthlayer.go @@ -898,3 +898,12 @@ func (s *MattermostAuthLayer) SendMessage(message, postType string, receipts []s return nil } + +func (s *MattermostAuthLayer) GetUserTimezone(userID string) (string, error) { + user, err := s.servicesAPI.GetUserByID(userID) + if err != nil { + return "", err + } + timezone := user.Timezone + return mmModel.GetPreferredTimezone(timezone), nil +} diff --git a/server/services/store/mockstore/mockstore.go b/server/services/store/mockstore/mockstore.go index 42e525d7b..c56fa17ba 100644 --- a/server/services/store/mockstore/mockstore.go +++ b/server/services/store/mockstore/mockstore.go @@ -925,6 +925,21 @@ func (mr *MockStoreMockRecorder) GetTeam(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeam", reflect.TypeOf((*MockStore)(nil).GetTeam), arg0) } +// GetTeamBoardsInsights mocks base method. +func (m *MockStore) GetTeamBoardsInsights(arg0, arg1 string, arg2 int64, arg3, arg4 int, arg5 []string) (*model.BoardInsightsList, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTeamBoardsInsights", arg0, arg1, arg2, arg3, arg4, arg5) + ret0, _ := ret[0].(*model.BoardInsightsList) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTeamBoardsInsights indicates an expected call of GetTeamBoardsInsights. +func (mr *MockStoreMockRecorder) GetTeamBoardsInsights(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTeamBoardsInsights", reflect.TypeOf((*MockStore)(nil).GetTeamBoardsInsights), arg0, arg1, arg2, arg3, arg4, arg5) +} + // GetTeamCount mocks base method. func (m *MockStore) GetTeamCount() (int64, error) { m.ctrl.T.Helper() @@ -985,6 +1000,21 @@ func (mr *MockStoreMockRecorder) GetUsedCardsCount() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsedCardsCount", reflect.TypeOf((*MockStore)(nil).GetUsedCardsCount)) } +// GetUserBoardsInsights mocks base method. +func (m *MockStore) GetUserBoardsInsights(arg0, arg1 string, arg2 int64, arg3, arg4 int, arg5 []string) (*model.BoardInsightsList, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserBoardsInsights", arg0, arg1, arg2, arg3, arg4, arg5) + ret0, _ := ret[0].(*model.BoardInsightsList) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserBoardsInsights indicates an expected call of GetUserBoardsInsights. +func (mr *MockStoreMockRecorder) GetUserBoardsInsights(arg0, arg1, arg2, arg3, arg4, arg5 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserBoardsInsights", reflect.TypeOf((*MockStore)(nil).GetUserBoardsInsights), arg0, arg1, arg2, arg3, arg4, arg5) +} + // GetUserByEmail mocks base method. func (m *MockStore) GetUserByEmail(arg0 string) (*model.User, error) { m.ctrl.T.Helper() @@ -1045,6 +1075,21 @@ func (mr *MockStoreMockRecorder) GetUserCategoryBoards(arg0, arg1 interface{}) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCategoryBoards", reflect.TypeOf((*MockStore)(nil).GetUserCategoryBoards), arg0, arg1) } +// GetUserTimezone mocks base method. +func (m *MockStore) GetUserTimezone(arg0 string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserTimezone", arg0) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserTimezone indicates an expected call of GetUserTimezone. +func (mr *MockStoreMockRecorder) GetUserTimezone(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserTimezone", reflect.TypeOf((*MockStore)(nil).GetUserTimezone), arg0) +} + // GetUsersByTeam mocks base method. func (m *MockStore) GetUsersByTeam(arg0 string) ([]*model.User, error) { m.ctrl.T.Helper() diff --git a/server/services/store/sqlstore/board_insights.go b/server/services/store/sqlstore/board_insights.go new file mode 100644 index 000000000..f98e662be --- /dev/null +++ b/server/services/store/sqlstore/board_insights.go @@ -0,0 +1,147 @@ +package sqlstore + +import ( + "database/sql" + "fmt" + "time" + + "github.com/mattermost/focalboard/server/model" + + sq "github.com/Masterminds/squirrel" + mmModel "github.com/mattermost/mattermost-server/v6/model" + + "github.com/mattermost/mattermost-server/v6/shared/mlog" +) + +func (s *SQLStore) getTeamBoardsInsights(db sq.BaseRunner, teamID string, userID string, since int64, offset int, limit int, boardIDs []string) (*model.BoardInsightsList, error) { + boardsHistoryQuery := s.getQueryBuilder(db). + Select("boards.id, boards.icon, boards.title, count(boards_history.id) as count, boards_history.modified_by, boards.created_by"). + From(s.tablePrefix + "boards_history as boards_history"). + Join(s.tablePrefix + "boards as boards on boards_history.id = boards.id"). + Where(sq.Gt{"boards_history.insert_at": mmModel.GetTimeForMillis(since).Format(time.RFC3339)}). + Where(sq.Eq{"boards.team_id": teamID}). + Where(sq.Eq{"boards.id": boardIDs}). + Where(sq.NotEq{"boards_history.modified_by": "system"}). + Where(sq.Eq{"boards.delete_at": 0}). + GroupBy("boards.id, boards_history.id, boards_history.modified_by") + + blocksHistoryQuery := s.getQueryBuilder(db). + Select("boards.id, boards.icon, boards.title, count(blocks_history.id) as count, blocks_history.modified_by, boards.created_by"). + Prefix("UNION ALL"). + From(s.tablePrefix + "blocks_history as blocks_history"). + Join(s.tablePrefix + "boards as boards on blocks_history.board_id = boards.id"). + Where(sq.Gt{"blocks_history.insert_at": mmModel.GetTimeForMillis(since).Format(time.RFC3339)}). + Where(sq.Eq{"boards.team_id": teamID}). + Where(sq.Eq{"boards.id": boardIDs}). + Where(sq.NotEq{"blocks_history.modified_by": "system"}). + Where(sq.Eq{"boards.delete_at": 0}). + GroupBy("boards.id, blocks_history.board_id, blocks_history.modified_by") + + boardsActivity := boardsHistoryQuery.SuffixExpr(blocksHistoryQuery) + + insightsQuery := s.getQueryBuilder(db).Select( + fmt.Sprintf("id, title, icon, sum(count) as activity_count, %s as active_users, created_by", s.concatenationSelector("distinct modified_by", ",")), + ). + FromSelect(boardsActivity, "boards_and_blocks_history"). + GroupBy("id, title, icon, created_by"). + OrderBy("activity_count desc"). + Offset(uint64(offset)). + Limit(uint64(limit)) + + rows, err := insightsQuery.Query() + if err != nil { + s.logger.Error(`Team insights query ERROR`, mlog.Err(err)) + return nil, err + } + defer s.CloseRows(rows) + + boardsInsights, err := boardsInsightsFromRows(rows) + if err != nil { + return nil, err + } + boardInsightsPaginated := model.GetTopBoardInsightsListWithPagination(boardsInsights, limit) + + return boardInsightsPaginated, nil +} + +func (s *SQLStore) getUserBoardsInsights(db sq.BaseRunner, teamID string, userID string, since int64, offset int, limit int, boardIDs []string) (*model.BoardInsightsList, error) { + boardsHistoryQuery := s.getQueryBuilder(db). + Select("boards.id, boards.icon, boards.title, count(boards_history.id) as count, boards_history.modified_by, boards.created_by"). + From(s.tablePrefix + "boards_history as boards_history"). + Join(s.tablePrefix + "boards as boards on boards_history.id = boards.id"). + Where(sq.Gt{"boards_history.insert_at": mmModel.GetTimeForMillis(since).Format(time.RFC3339)}). + Where(sq.Eq{"boards.team_id": teamID}). + Where(sq.Eq{"boards.id": boardIDs}). + Where(sq.NotEq{"boards_history.modified_by": "system"}). + Where(sq.Eq{"boards.delete_at": 0}). + GroupBy("boards.id, boards_history.id, boards_history.modified_by") + + blocksHistoryQuery := s.getQueryBuilder(db). + Select("boards.id, boards.icon, boards.title, count(blocks_history.id) as count, blocks_history.modified_by, boards.created_by"). + Prefix("UNION ALL"). + From(s.tablePrefix + "blocks_history as blocks_history"). + Join(s.tablePrefix + "boards as boards on blocks_history.board_id = boards.id"). + Where(sq.Gt{"blocks_history.insert_at": mmModel.GetTimeForMillis(since).Format(time.RFC3339)}). + Where(sq.Eq{"boards.team_id": teamID}). + Where(sq.Eq{"boards.id": boardIDs}). + Where(sq.NotEq{"blocks_history.modified_by": "system"}). + Where(sq.Eq{"boards.delete_at": 0}). + GroupBy("boards.id, blocks_history.board_id, blocks_history.modified_by") + + boardsActivity := boardsHistoryQuery.SuffixExpr(blocksHistoryQuery) + + insightsQuery := s.getQueryBuilder(db).Select( + fmt.Sprintf("id, title, icon, sum(count) as activity_count, %s as active_users, created_by", s.concatenationSelector("distinct modified_by", ",")), + ). + FromSelect(boardsActivity, "boards_and_blocks_history"). + GroupBy("id, title, icon, created_by"). + OrderBy("activity_count desc") + + userQuery := s.getQueryBuilder(db).Select("*"). + FromSelect(insightsQuery, "boards_and_blocks_history_for_user"). + Where(sq.Or{ + sq.Eq{ + "created_by": userID, + }, + sq.Expr(s.elementInColumn("active_users"), userID), + }). + Offset(uint64(offset)). + Limit(uint64(limit)) + + rows, err := userQuery.Query() + + if err != nil { + s.logger.Error(`Team insights query ERROR`, mlog.Err(err)) + return nil, err + } + defer s.CloseRows(rows) + + boardsInsights, err := boardsInsightsFromRows(rows) + if err != nil { + return nil, err + } + boardInsightsPaginated := model.GetTopBoardInsightsListWithPagination(boardsInsights, limit) + + return boardInsightsPaginated, nil +} + +func boardsInsightsFromRows(rows *sql.Rows) ([]*model.BoardInsight, error) { + boardsInsights := []*model.BoardInsight{} + for rows.Next() { + var boardInsight model.BoardInsight + + err := rows.Scan( + &boardInsight.BoardID, + &boardInsight.Title, + &boardInsight.Icon, + &boardInsight.ActivityCount, + &boardInsight.ActiveUsers, + &boardInsight.CreatedBy, + ) + if err != nil { + return nil, err + } + boardsInsights = append(boardsInsights, &boardInsight) + } + return boardsInsights, nil +} diff --git a/server/services/store/sqlstore/public_methods.go b/server/services/store/sqlstore/public_methods.go index 271f7dd30..968e48a9e 100644 --- a/server/services/store/sqlstore/public_methods.go +++ b/server/services/store/sqlstore/public_methods.go @@ -469,6 +469,11 @@ func (s *SQLStore) GetTeam(ID string) (*model.Team, error) { } +func (s *SQLStore) GetTeamBoardsInsights(teamID string, userID string, since int64, offset int, limit int, boardIDs []string) (*model.BoardInsightsList, error) { + return s.getTeamBoardsInsights(s.db, teamID, userID, since, offset, limit, boardIDs) + +} + func (s *SQLStore) GetTeamCount() (int64, error) { return s.getTeamCount(s.db) @@ -489,6 +494,11 @@ func (s *SQLStore) GetUsedCardsCount() (int, error) { } +func (s *SQLStore) GetUserBoardsInsights(teamID string, userID string, since int64, offset int, limit int, boardIDs []string) (*model.BoardInsightsList, error) { + return s.getUserBoardsInsights(s.db, teamID, userID, since, offset, limit, boardIDs) + +} + func (s *SQLStore) GetUserByEmail(email string) (*model.User, error) { return s.getUserByEmail(s.db, email) @@ -509,6 +519,11 @@ func (s *SQLStore) GetUserCategoryBoards(userID string, teamID string) ([]model. } +func (s *SQLStore) GetUserTimezone(userID string) (string, error) { + return s.getUserTimezone(s.db, userID) + +} + func (s *SQLStore) GetUsersByTeam(teamID string) ([]*model.User, error) { return s.getUsersByTeam(s.db, teamID) diff --git a/server/services/store/sqlstore/sqlstore.go b/server/services/store/sqlstore/sqlstore.go index e146093c2..9d58f965e 100644 --- a/server/services/store/sqlstore/sqlstore.go +++ b/server/services/store/sqlstore/sqlstore.go @@ -2,6 +2,7 @@ package sqlstore import ( "database/sql" + "fmt" "net/url" sq "github.com/Masterminds/squirrel" @@ -118,6 +119,29 @@ func (s *SQLStore) escapeField(fieldName string) string { return fieldName } +func (s *SQLStore) concatenationSelector(field string, delimiter string) string { + if s.dbType == model.SqliteDBType { + return fmt.Sprintf("group_concat(%s)", field) + } + if s.dbType == model.PostgresDBType { + return fmt.Sprintf("string_agg(%s, '%s')", field, delimiter) + } + if s.dbType == model.MysqlDBType { + return fmt.Sprintf("GROUP_CONCAT(%s SEPARATOR '%s')", field, delimiter) + } + return "" +} + +func (s *SQLStore) elementInColumn(column string) string { + if s.dbType == model.SqliteDBType || s.dbType == model.MysqlDBType { + return fmt.Sprintf("instr(%s, ?) > 0", column) + } + if s.dbType == model.PostgresDBType { + return fmt.Sprintf("position(? in %s) > 0", column) + } + return "" +} + func (s *SQLStore) getLicense(db sq.BaseRunner) *mmModel.License { return nil } diff --git a/server/services/store/sqlstore/sqlstore_test.go b/server/services/store/sqlstore/sqlstore_test.go index 6aed21a06..9f9c29ac1 100644 --- a/server/services/store/sqlstore/sqlstore_test.go +++ b/server/services/store/sqlstore/sqlstore_test.go @@ -6,7 +6,9 @@ package sqlstore import ( "testing" + "github.com/mattermost/focalboard/server/model" "github.com/mattermost/focalboard/server/services/store/storetests" + "github.com/stretchr/testify/require" ) func TestSQLStore(t *testing.T) { @@ -23,4 +25,39 @@ func TestSQLStore(t *testing.T) { 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) }) + t.Run("BoardsInsightsStore", func(t *testing.T) { storetests.StoreTestBoardsInsightsStore(t, SetupTests) }) +} + +// tests for utility functions inside sqlstore.go + +func TestConcatenationSelector(t *testing.T) { + store, tearDown := SetupTests(t) + sqlStore := store.(*SQLStore) + defer tearDown() + + concatenationString := sqlStore.concatenationSelector("a", ",") + switch sqlStore.dbType { + case model.SqliteDBType: + require.Equal(t, concatenationString, "group_concat(a)") + case model.MysqlDBType: + require.Equal(t, concatenationString, "GROUP_CONCAT(a SEPARATOR ',')") + case model.PostgresDBType: + require.Equal(t, concatenationString, "string_agg(a, ',')") + } +} + +func TestElementInColumn(t *testing.T) { + store, tearDown := SetupTests(t) + sqlStore := store.(*SQLStore) + defer tearDown() + + inLiteral := sqlStore.elementInColumn("test_column") + switch sqlStore.dbType { + case model.SqliteDBType: + require.Equal(t, inLiteral, "instr(test_column, ?) > 0") + case model.MysqlDBType: + require.Equal(t, inLiteral, "instr(test_column, ?) > 0") + case model.PostgresDBType: + require.Equal(t, inLiteral, "position(? in test_column) > 0") + } } diff --git a/server/services/store/sqlstore/user.go b/server/services/store/sqlstore/user.go index 9fda74c11..3fcf9b6cb 100644 --- a/server/services/store/sqlstore/user.go +++ b/server/services/store/sqlstore/user.go @@ -278,3 +278,7 @@ func (s *SQLStore) patchUserProps(db sq.BaseRunner, userID string, patch model.U func (s *SQLStore) sendMessage(db sq.BaseRunner, message, postType string, receipts []string) error { return errUnsupportedOperation } + +func (s *SQLStore) getUserTimezone(_ sq.BaseRunner, _ string) (string, error) { + return "", errUnsupportedOperation +} diff --git a/server/services/store/store.go b/server/services/store/store.go index 143a80dff..f56e4ad4b 100644 --- a/server/services/store/store.go +++ b/server/services/store/store.go @@ -156,6 +156,11 @@ type Store interface { SearchUserChannels(teamID, userID, query string) ([]*mmModel.Channel, error) GetChannel(teamID, channelID string) (*mmModel.Channel, error) SendMessage(message, postType string, receipts []string) error + + // Insights + GetTeamBoardsInsights(teamID string, userID string, since int64, offset int, limit int, boardIDs []string) (*model.BoardInsightsList, error) + GetUserBoardsInsights(teamID string, userID string, since int64, offset int, limit int, boardIDs []string) (*model.BoardInsightsList, error) + GetUserTimezone(userID string) (string, error) } type NotSupportedError struct { diff --git a/server/services/store/storetests/board_insights.go b/server/services/store/storetests/board_insights.go new file mode 100644 index 000000000..df1b6b114 --- /dev/null +++ b/server/services/store/storetests/board_insights.go @@ -0,0 +1,99 @@ +package storetests + +import ( + "testing" + + "github.com/mattermost/focalboard/server/model" + "github.com/mattermost/focalboard/server/services/store" + "github.com/stretchr/testify/require" +) + +const ( + testInsightsUserID1 = "user-id-1" +) + +func StoreTestBoardsInsightsStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) { + t.Run("GetBoardsInsights", func(t *testing.T) { + store, tearDown := setup(t) + defer tearDown() + getBoardsInsightsTest(t, store) + }) +} + +func getBoardsInsightsTest(t *testing.T, store store.Store) { + // creating sample data + teamID := testTeamID + userID := testUserID + newBab := &model.BoardsAndBlocks{ + Boards: []*model.Board{ + {ID: "board-id-1", TeamID: teamID, Type: model.BoardTypeOpen, Icon: "💬"}, + {ID: "board-id-2", TeamID: teamID, Type: model.BoardTypePrivate}, + {ID: "board-id-3", TeamID: teamID, Type: model.BoardTypeOpen}, + }, + Blocks: []model.Block{ + {ID: "block-id-1", BoardID: "board-id-1", Type: model.TypeCard}, + {ID: "block-id-2", BoardID: "board-id-2", Type: model.TypeCard}, + {ID: "block-id-3", BoardID: "board-id-1", Type: model.TypeCard}, + {ID: "block-id-4", BoardID: "board-id-2", Type: model.TypeCard}, + {ID: "block-id-5", BoardID: "board-id-1", Type: model.TypeCard}, + {ID: "block-id-6", BoardID: "board-id-2", Type: model.TypeCard}, + {ID: "block-id-7", BoardID: "board-id-1", Type: model.TypeCard}, + {ID: "block-id-8", BoardID: "board-id-2", Type: model.TypeCard}, + {ID: "block-id-9", BoardID: "board-id-1", Type: model.TypeCard}, + {ID: "block-id-10", BoardID: "board-id-3", Type: model.TypeCard}, + {ID: "block-id-11", BoardID: "board-id-3", Type: model.TypeCard}, + {ID: "block-id-12", BoardID: "board-id-3", Type: model.TypeCard}, + }, + } + + bab, err := store.CreateBoardsAndBlocks(newBab, userID) + require.Nil(t, err) + require.NotNil(t, bab) + + newBab = &model.BoardsAndBlocks{ + Blocks: []model.Block{ + {ID: "block-id-13", BoardID: "board-id-1", Type: model.TypeCard}, + {ID: "block-id-14", BoardID: "board-id-1", Type: model.TypeCard}, + }, + } + bab, err = store.CreateBoardsAndBlocks(newBab, testInsightsUserID1) + require.Nil(t, err) + require.NotNil(t, bab) + bm := &model.BoardMember{ + UserID: userID, + BoardID: "board-id-2", + SchemeAdmin: true, + } + + _, _ = store.SaveMember(bm) + + boardsUser1, _ := store.GetBoardsForUserAndTeam(testUserID, testTeamID) + boardsUser2, _ := store.GetBoardsForUserAndTeam(testInsightsUserID1, testTeamID) + // t.Run("team insights", func(t *testing.T) { + // boardIDs := []string{boardsUser1[0].ID, boardsUser1[1].ID, boardsUser1[2].ID} + // topTeamBoards, err := store.GetTeamBoardsInsights(testTeamID, testUserID, + // 0, 0, 10, boardIDs) + // require.NoError(t, err) + // require.Len(t, topTeamBoards.Items, 3) + // // validate board insight content + // require.Equal(t, topTeamBoards.Items[0].ActivityCount, strconv.Itoa(8)) + // require.Equal(t, topTeamBoards.Items[0].Icon, "💬") + // require.Equal(t, topTeamBoards.Items[1].ActivityCount, strconv.Itoa(5)) + // require.Equal(t, topTeamBoards.Items[2].ActivityCount, strconv.Itoa(4)) + + // }) + + t.Run("user insights", func(t *testing.T) { + boardIDs := []string{boardsUser1[0].ID, boardsUser1[1].ID, boardsUser1[2].ID} + topUser1Boards, err := store.GetUserBoardsInsights(testTeamID, testUserID, 0, 0, 10, boardIDs) + require.NoError(t, err) + require.Len(t, topUser1Boards.Items, 3) + require.Equal(t, topUser1Boards.Items[0].Icon, "💬") + require.Equal(t, topUser1Boards.Items[0].BoardID, "board-id-1") + boardIDs = []string{boardsUser2[0].ID, boardsUser2[1].ID} + topUser2Boards, err := store.GetUserBoardsInsights(testTeamID, testInsightsUserID1, 0, 0, 10, boardIDs) + require.NoError(t, err) + require.Len(t, topUser2Boards.Items, 1) + require.Equal(t, topUser2Boards.Items[0].BoardID, "board-id-1") + }) +} diff --git a/webapp/src/insights/index.ts b/webapp/src/insights/index.ts new file mode 100644 index 000000000..e81e9062e --- /dev/null +++ b/webapp/src/insights/index.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export type TopBoard = { + boardID: string; + icon: string; + title: string; + activityCount: number; + activeUsers: string; + createdBy: string; +} + +export type TopBoardResponse = { + has_next: boolean; + items: TopBoard[]; +} diff --git a/webapp/src/octoClient.ts b/webapp/src/octoClient.ts index 4f4ff1785..bed652bfd 100644 --- a/webapp/src/octoClient.ts +++ b/webapp/src/octoClient.ts @@ -16,6 +16,7 @@ import {PrepareOnboardingResponse} from './onboardingTour' import {Constants} from "./constants" import {BoardsCloudLimits} from './boardsCloudLimits' +import {TopBoardResponse} from './insights' // // OctoClient is the client interface to the server APIs @@ -900,6 +901,27 @@ class OctoClient { Utils.log(`Cloud limits: cards=${limits.cards} views=${limits.views}`) return limits } + + // insights + async getMyTopBoards(timeRange: string, page: number, perPage: number, teamId: string): Promise { + const path = `/api/v2/users/me/boards/insights?time_range=${timeRange}&page=${page}&per_page=${perPage}&team_id=${teamId}` + const response = await fetch(this.getBaseURL() + path, {headers: this.headers()}) + if (response.status !== 200) { + return undefined + } + + return (await this.getJson(response, {})) as TopBoardResponse + } + + async getTeamTopBoards(timeRange: string, page: number, perPage: number, teamId: string): Promise { + const path = `/api/v2/teams/${teamId}/boards/insights?time_range=${timeRange}&page=${page}&per_page=${perPage}` + const response = await fetch(this.getBaseURL() + path, {headers: this.headers()}) + if (response.status !== 200) { + return undefined + } + + return (await this.getJson(response, {})) as TopBoardResponse + } } const octoClient = new OctoClient()