Feature: board insights - against 7.0 release (#3086)

* Add user, team boards endpoint

* Revert accidental  mmModel import delete

* Bump squirrel version to 1.5.2

* Change queries to separately handle private and public boards

* Use utils.GetMillisForTime instead of time library function which isn't compatible with older go versions

* Add squirrel to linux/go.sum

* Add authorization check for user accessing team insights, comment and lint modifications for board_insights store

* Add storetests for board insights and utilities

* Improve team insights tests

* Refactor insights queries, add user insights tests

* Fix durationSelector tests

* Optimize iconPopulate query, add more app,store tests for insights feature

* Remove unnecessary go-bindata package metadata update

* Add license and guest user checks

* blank identifier for unused parameter in isUserGuest

* Fix user-id in test

* Undo Makefile changes

* Make teamID a query parameter

* Handle time conversion error in sqlstore

* Remove redundant fmt logs, lint fixes

* Lint fix

* Add features to paginate response, change duration param to -> time_range

* Fix swagger docstring for insights endpoints

* lint fixes

* fix reading of page parameter into per_page

* initial commit for focalboard insights endpoint

* adding client function

* Add workspace_id to response

* Lint fixes

* renaming registry function

* fixing lint

* fixing lint

* Fix http codes when parameters are invalid

* Include public boards for insights, fix user insights WHERE clause bug

* Lint fixes

* Lint squirrel query

* Fix ctx instantization, name of auth function which checks if user is in a team

* Remove redundant constants declaration, improve guest rejection message, add team permission check for getUserInsights

* Merge queries for private and public workspaces for user into one

* Fix timestamp generation used for filtering boards

* Update mattermost-server version to 182ae1234a49d0c0a2867905234e93b25caa003a

* Run 'go mod tidy' in modules

* Replace UnixMilli() since it's incompatible with go=1.16 in linux CI

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Co-authored-by: Benjamin Cooke <benjamincooke@Benjamins-MacBook-Pro.local>
Co-authored-by: Ben Cooke <benkcooke@gmail.com>
This commit is contained in:
Shivashis Padhi 2022-06-21 09:13:46 +05:30 committed by GitHub
parent 747efba206
commit c4c4ce5578
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 2411 additions and 302 deletions

View file

@ -7,6 +7,6 @@ replace github.com/mattermost/focalboard/server => ../server
require (
github.com/google/uuid v1.3.0
github.com/mattermost/focalboard/server v0.0.0-20210422230105-f5ae0b265a8d
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49
github.com/webview/webview v0.0.0-20200724072439-e0c01595b361
)

View file

@ -1128,8 +1128,8 @@ github.com/mattermost/logr/v2 v2.0.15/go.mod h1:mpPp935r5dIkFDo2y9Q87cQWhFR/4xXp
github.com/mattermost/mattermost-plugin-api v0.0.21 h1:0uQ9kQwFFEvb+qxwXqwbaDGmxwwv3wv6iHdhMWG3KiA=
github.com/mattermost/mattermost-plugin-api v0.0.21/go.mod h1:qz19Y+5HLbjtzY2RZ6B8ZZx90XUrmoRsHkSAfvfKU1M=
github.com/mattermost/mattermost-server/v6 v6.0.0-20210901153517-42e75fad4dae/go.mod h1:kmxJuVgpX13Th+e5L1ZsBs4aq+ETmmDg9joo5r4cIw8=
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee h1:4zobTblf+33T9Y6SVXy0zX1YJ999Tyxff7unycul82s=
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee/go.mod h1:HBSu5YC0k8TLb+7DFFB9/63/+oBZj7pgx8K07lHmzyI=
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49 h1:7uFRdTX54qUp7jNodn0+vrhxuwnudHxWRv7kLN72Fig=
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49/go.mod h1:HBSu5YC0k8TLb+7DFFB9/63/+oBZj7pgx8K07lHmzyI=
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8/go.mod h1:jxM3g1bx+k2Thz7jofcHguBS8TZn5Pc+o5MGmORObhw=
github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0/go.mod h1:nV5bfVpT//+B1RPD2JvRnxbkLmJEYXmRaaVl15fsXjs=
github.com/mattermost/squirrel v0.2.0/go.mod h1:NPPtk+CdpWre4GxMGoOpzEVFVc0ZoEFyJBZGCtn9nSU=

View file

@ -4,8 +4,8 @@ go 1.12
require (
github.com/go-git/go-git/v5 v5.1.0
github.com/mattermost/mattermost-server/v6 v6.0.0-20210817091833-04b27ce93c02
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.7.0
github.com/stretchr/testify v1.7.1
sigs.k8s.io/yaml v1.2.0
)

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@ replace github.com/mattermost/focalboard/server => ../server
require (
github.com/mattermost/focalboard/server v0.0.0-20210525112228-f43e4028dbdc
github.com/mattermost/mattermost-plugin-api v0.0.21
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49
github.com/prometheus/common v0.31.1 // indirect
github.com/stretchr/testify v1.7.1
)

View file

@ -1128,8 +1128,8 @@ github.com/mattermost/logr/v2 v2.0.15/go.mod h1:mpPp935r5dIkFDo2y9Q87cQWhFR/4xXp
github.com/mattermost/mattermost-plugin-api v0.0.21 h1:0uQ9kQwFFEvb+qxwXqwbaDGmxwwv3wv6iHdhMWG3KiA=
github.com/mattermost/mattermost-plugin-api v0.0.21/go.mod h1:qz19Y+5HLbjtzY2RZ6B8ZZx90XUrmoRsHkSAfvfKU1M=
github.com/mattermost/mattermost-server/v6 v6.0.0-20210901153517-42e75fad4dae/go.mod h1:kmxJuVgpX13Th+e5L1ZsBs4aq+ETmmDg9joo5r4cIw8=
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee h1:4zobTblf+33T9Y6SVXy0zX1YJ999Tyxff7unycul82s=
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee/go.mod h1:HBSu5YC0k8TLb+7DFFB9/63/+oBZj7pgx8K07lHmzyI=
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49 h1:7uFRdTX54qUp7jNodn0+vrhxuwnudHxWRv7kLN72Fig=
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49/go.mod h1:HBSu5YC0k8TLb+7DFFB9/63/+oBZj7pgx8K07lHmzyI=
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8/go.mod h1:jxM3g1bx+k2Thz7jofcHguBS8TZn5Pc+o5MGmORObhw=
github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0/go.mod h1:nV5bfVpT//+B1RPD2JvRnxbkLmJEYXmRaaVl15fsXjs=
github.com/mattermost/squirrel v0.2.0/go.mod h1:NPPtk+CdpWre4GxMGoOpzEVFVc0ZoEFyJBZGCtn9nSU=

View file

@ -253,6 +253,21 @@ export default class Plugin {
}
}
// 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
});
}
// register websocket handlers
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BLOCK}`, (e: any) => wsClient.updateBlockHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CLIENT_CONFIG}`, (e: any) => wsClient.updateClientConfigHandler(e.data))

View file

@ -15,6 +15,7 @@ export interface PluginRegistry {
registerWebSocketEventHandler(event: string, handler: (e: any) => void)
unregisterWebSocketEventHandler(event: string)
registerAppBarComponent(iconURL: string, action: (channel: Channel, member: ChannelMembership) => void, tooltipText: React.ReactNode)
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
}

View file

@ -20,6 +20,7 @@ import (
"github.com/mattermost/focalboard/server/services/store"
"github.com/mattermost/focalboard/server/utils"
mmModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
@ -101,6 +102,10 @@ func (a *API) RegisterRoutes(r *mux.Router) {
apiv1.HandleFunc("/workspaces", a.sessionRequired(a.handleGetUserWorkspaces)).Methods("GET")
// Insights APIs
apiv1.HandleFunc("/teams/{teamID}/boards/insights", a.sessionRequired(a.handleTeamBoardsInsights)).Methods("GET")
apiv1.HandleFunc("/users/me/boards/insights", a.sessionRequired(a.handleUserBoardsInsights)).Methods("GET")
// Get Files API
files := r.PathPrefix("/files").Subrouter()
@ -125,7 +130,200 @@ func (a *API) RegisterRoutes(r *mux.Router) {
// limits
apiv1.HandleFunc("/limits", a.sessionRequired(a.handleCloudLimits)).Methods("GET")
}
func (a *API) handleTeamBoardsInsights(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /teams/{teamID}/boards/insights getTeamBoardsInsights
//
// 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"
session := r.Context().Value(sessionContextKey).(*model.Session)
userID := session.UserID
vars := mux.Vars(r)
teamID := vars["teamID"]
query := r.URL.Query()
timeRange := query.Get("time_range")
if !a.app.HasPermissionToTeam(userID, teamID) {
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
}
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
}
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("teamBoardInsightCount", len(boardsInsights.Items))
auditRec.Success()
}
func (a *API) handleUserBoardsInsights(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /users/me/boards/insights getUserBoardsInsights
//
// Returns team boards insights
//
// ---
// produces:
// - application/json
// parameters:
// - name: team_id
// in: query
// description: teamID of the boards to be considered.
// 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"
session := r.Context().Value(sessionContextKey).(*model.Session)
userID := session.UserID
query := r.URL.Query()
teamID := query.Get("team_id")
timeRange := query.Get("time_range")
if !a.app.HasPermissionToTeam(userID, teamID) {
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
}
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
}
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) RegisterAdminRoutes(r *mux.Router) {
r.HandleFunc("/api/v1/admin/users/{username}/password", a.adminRequired(a.handleAdminSetPassword)).Methods("POST")
}

68
server/app/insights.go Normal file
View file

@ -0,0 +1,68 @@
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
licenseAndGuestCheckFlag, err := licenseAndGuestCheck(a, userID)
if !licenseAndGuestCheckFlag {
return nil, err
}
channels, err := a.store.GetUserWorkspacesInTeam(userID, teamID)
if err != nil {
return nil, err
}
channelIDs := make([]string, len(channels))
for index, channel := range channels {
channelIDs[index] = channel.ID
}
return a.store.GetTeamBoardsInsights(channelIDs, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage)
}
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
licenseAndGuestCheckFlag, err := licenseAndGuestCheck(a, userID)
if !licenseAndGuestCheckFlag {
return nil, err
}
channels, err := a.store.GetUserWorkspacesInTeam(userID, teamID)
if err != nil {
return nil, err
}
channelIDs := make([]string, len(channels))
for index, channel := range channels {
channelIDs[index] = channel.ID
}
return a.store.GetUserBoardsInsights(userID, channelIDs, opts.StartUnixMilli, opts.Page*opts.PerPage, opts.PerPage)
}
func licenseAndGuestCheck(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
}
isGuest, err := a.store.IsUserGuest(userID)
if err != nil {
return false, err
}
if lic.SkuShortName != mmModel.LicenseShortSkuProfessional && lic.SkuShortName != mmModel.LicenseShortSkuEnterprise {
return false, licenseError
}
if isGuest {
return false, guestError
}
return true, nil
}
func (a *App) GetUserTimezone(userID string) (string, error) {
return a.store.GetUserTimezone(userID)
}

View file

@ -0,0 +1,93 @@
package app
import (
"testing"
"github.com/mattermost/focalboard/server/model"
mmModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/require"
)
var mockInsightsWorkspaces = []model.UserWorkspace{
{
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 TestGetTeamBoardsInsights(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("success query", func(t *testing.T) {
th.Store.EXPECT().IsUserGuest("user-id").Return(false, nil)
fakeLicense := &mmModel.License{Features: &mmModel.Features{}, SkuShortName: mmModel.LicenseShortSkuEnterprise}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
th.Store.EXPECT().GetUserWorkspacesInTeam("user-id", "team-id").Return(mockInsightsWorkspaces, nil)
th.Store.EXPECT().GetTeamBoardsInsights([]string{"mock-user-workspace-id"}, int64(0), 0, 10).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)
})
t.Run("fail query", func(t *testing.T) {
th.Store.EXPECT().IsUserGuest("user-id").Return(false, nil)
fakeLicense := &mmModel.License{Features: &mmModel.Features{}, SkuShortName: mmModel.LicenseShortSkuEnterprise}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
th.Store.EXPECT().GetUserWorkspacesInTeam("user-id", "team-id").Return(mockInsightsWorkspaces, nil)
th.Store.EXPECT().GetTeamBoardsInsights([]string{"mock-user-workspace-id"}, int64(0), 0, 10).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"})
})
}
func TestGetUserBoardsInsights(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("success query", func(t *testing.T) {
th.Store.EXPECT().IsUserGuest("user-id-1").Return(false, nil)
fakeLicense := &mmModel.License{Features: &mmModel.Features{}, SkuShortName: mmModel.LicenseShortSkuEnterprise}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
th.Store.EXPECT().GetUserWorkspacesInTeam("user-id-1", "team-id").Return(mockInsightsWorkspaces, nil)
th.Store.EXPECT().GetUserBoardsInsights("user-id-1", []string{"mock-user-workspace-id"}, int64(0), 0, 10).Return(mockTeamInsightsList, nil)
results, err := th.App.GetUserBoardsInsights("user-id-1", "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) {
th.Store.EXPECT().IsUserGuest("user-id-1").Return(false, nil)
fakeLicense := &mmModel.License{Features: &mmModel.Features{}, SkuShortName: mmModel.LicenseShortSkuEnterprise}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
th.Store.EXPECT().GetUserWorkspacesInTeam("user-id-1", "team-id").Return(mockInsightsWorkspaces, nil)
th.Store.EXPECT().GetUserBoardsInsights("user-id-1", []string{"mock-user-workspace-id"},
int64(0), 0, 10).Return(nil, insightError{"board-insight-error"})
_, err := th.App.GetUserBoardsInsights("user-id-1", "team-id", &mmModel.InsightsOpts{StartUnixMilli: 0, Page: 0, PerPage: 10})
require.Error(t, err)
require.ErrorIs(t, err, insightError{"board-insight-error"})
})
}

View file

@ -18,3 +18,7 @@ func (a *App) UpdateUserConfig(userID string, patch model.UserPropPatch) (map[st
return user.Props, nil
}
func (a *App) HasPermissionToTeam(userID string, teamID string) bool {
return a.store.HasPermissionToTeam(userID, teamID)
}

View file

@ -443,6 +443,20 @@ func (c *Client) GetSubscriptions(workspaceID string, subscriberID string) ([]*m
return subs, BuildResponse(r)
}
func (c *Client) GetTeamRoute(id string) string {
return fmt.Sprintf("/teams/%s", id)
}
func (c *Client) GetTeamBoardsInsights(teamID string, duration string) ([]model.BoardInsight, *Response) {
r, err := c.DoAPIGet(c.GetTeamRoute(teamID)+"/boards/insights?duration="+duration, "")
if err != nil {
return nil, BuildErrorResponse(r, err)
}
defer closeBody(r)
return model.BoardInsightsFromJSON(r.Body), BuildResponse(r)
}
func (c *Client) GetLimits() (*model.BoardsCloudLimits, *Response) {
r, err := c.DoAPIGet("/limits", "")
if err != nil {

View file

@ -12,7 +12,7 @@ require (
github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94
github.com/lib/pq v1.10.5
github.com/mattermost/mattermost-plugin-api v0.0.21
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49
github.com/mattn/go-sqlite3 v2.0.3+incompatible
github.com/oklog/run v1.1.0
github.com/pkg/errors v0.9.1

View file

@ -1128,8 +1128,8 @@ github.com/mattermost/logr/v2 v2.0.15/go.mod h1:mpPp935r5dIkFDo2y9Q87cQWhFR/4xXp
github.com/mattermost/mattermost-plugin-api v0.0.21 h1:0uQ9kQwFFEvb+qxwXqwbaDGmxwwv3wv6iHdhMWG3KiA=
github.com/mattermost/mattermost-plugin-api v0.0.21/go.mod h1:qz19Y+5HLbjtzY2RZ6B8ZZx90XUrmoRsHkSAfvfKU1M=
github.com/mattermost/mattermost-server/v6 v6.0.0-20210901153517-42e75fad4dae/go.mod h1:kmxJuVgpX13Th+e5L1ZsBs4aq+ETmmDg9joo5r4cIw8=
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee h1:4zobTblf+33T9Y6SVXy0zX1YJ999Tyxff7unycul82s=
github.com/mattermost/mattermost-server/v6 v6.0.0-20220518113816-388842803cee/go.mod h1:HBSu5YC0k8TLb+7DFFB9/63/+oBZj7pgx8K07lHmzyI=
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49 h1:7uFRdTX54qUp7jNodn0+vrhxuwnudHxWRv7kLN72Fig=
github.com/mattermost/mattermost-server/v6 v6.0.0-20220613202234-182ae1234a49/go.mod h1:HBSu5YC0k8TLb+7DFFB9/63/+oBZj7pgx8K07lHmzyI=
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8/go.mod h1:jxM3g1bx+k2Thz7jofcHguBS8TZn5Pc+o5MGmORObhw=
github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0/go.mod h1:nV5bfVpT//+B1RPD2JvRnxbkLmJEYXmRaaVl15fsXjs=
github.com/mattermost/squirrel v0.2.0/go.mod h1:NPPtk+CdpWre4GxMGoOpzEVFVc0ZoEFyJBZGCtn9nSU=

View file

@ -0,0 +1,65 @@
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 int `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"`
// WorkspaceID of the board
WorkspaceID string `json:"workspaceID"`
}
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}
}

View file

@ -24,11 +24,11 @@ import (
)
const (
sqliteDBType = "sqlite3"
postgresDBType = "postgres"
mysqlDBType = "mysql"
directChannelType = "D"
sqliteDBType = "sqlite3"
postgresDBType = "postgres"
mysqlDBType = "mysql"
nonTemplateFilterMySQL = "focalboard_blocks.fields LIKE '%\"isTemplate\":false%'"
nonTemplateFilterPostgres = "focalboard_blocks.fields ->> 'isTemplate' = 'false'"
)
var (
@ -428,6 +428,64 @@ func (s *MattermostAuthLayer) GetUserWorkspaces(userID string) ([]model.UserWork
return s.userWorkspacesFromRows(rows)
}
func (s *MattermostAuthLayer) GetUserWorkspacesInTeam(userID string, teamID string) ([]model.UserWorkspace, error) {
var memberWorkspacesQuery, accessibleWorkspacesQuery sq.SelectBuilder
var nonTemplateFilter string
switch s.dbType {
case mysqlDBType:
nonTemplateFilter = nonTemplateFilterMySQL
case postgresDBType:
nonTemplateFilter = nonTemplateFilterPostgres
default:
return nil, fmt.Errorf("GetUserWorkspaces - %w", errUnsupportedDatabaseError)
}
memberWorkspacesQuery = s.getQueryBuilder().
Select("Channels.ID", "Channels.DisplayName", "COUNT(focalboard_blocks.id)", "Channels.Type", "Channels.Name").
From("ChannelMembers").
// select channels without a corresponding workspace
LeftJoin(
"focalboard_blocks ON focalboard_blocks.workspace_id = ChannelMembers.ChannelId AND "+
"focalboard_blocks.type = 'board' AND "+
nonTemplateFilter,
).
Join("Channels ON ChannelMembers.ChannelId = Channels.Id").
Where(sq.Eq{"ChannelMembers.UserId": userID}).
Where(sq.Eq{"Channels.TeamID": teamID}).
GroupBy("Channels.Id", "Channels.DisplayName")
accessibleWorkspacesQuery = s.getQueryBuilder().
Select("pc.ID", "pc.DisplayName", "COUNT(focalboard_blocks.id)", "'O' as Type", "pc.Name").Prefix(" UNION ALL ").
From("PublicChannels as pc").
// select channels without a corresponding workspace
LeftJoin(
"focalboard_blocks ON focalboard_blocks.workspace_id = pc.ID AND "+
"focalboard_blocks.type = 'board' AND "+
nonTemplateFilter,
).
Where(sq.Eq{"pc.TeamID": teamID}).
GroupBy("pc.Id", "pc.DisplayName")
workspacesQuery := memberWorkspacesQuery.SuffixExpr(accessibleWorkspacesQuery)
rows, err := workspacesQuery.Query()
if err != nil {
s.logger.Error("ERROR GetUserWorkspaces", mlog.Err(err))
return nil, err
}
defer s.CloseRows(rows)
memberWorkspaces, err := s.userWorkspacesFromRows(rows)
if err != nil {
s.logger.Error("ERROR userWorkspacesFromRows", mlog.Err(err))
return nil, err
}
return memberWorkspaces, nil
}
type UserWorkspaceRawModel struct {
ID string `json:"id"`
Title string `json:"title"`
@ -456,7 +514,7 @@ func (s *MattermostAuthLayer) userWorkspacesFromRows(rows *sql.Rows) ([]model.Us
return nil, err
}
if rawUserWorkspace.Type == directChannelType {
if rawUserWorkspace.Type == string(mmModel.ChannelTypeDirect) {
userIDs := strings.Split(rawUserWorkspace.Name, "__")
usersToFetch = append(usersToFetch, userIDs...)
}
@ -477,7 +535,7 @@ func (s *MattermostAuthLayer) userWorkspacesFromRows(rows *sql.Rows) ([]model.Us
userWorkspaces := []model.UserWorkspace{}
for i := range rawUserWorkspaces {
if rawUserWorkspaces[i].Type == directChannelType {
if rawUserWorkspaces[i].Type == string(mmModel.ChannelTypeDirect) {
userIDs := strings.Split(rawUserWorkspaces[i].Name, "__")
names := []string{}
@ -674,6 +732,34 @@ func (s *MattermostAuthLayer) GetLicense() *mmModel.License {
return s.pluginAPI.GetLicense()
}
func (s *MattermostAuthLayer) HasPermissionToTeam(userID string, teamID string) bool {
return s.pluginAPI.HasPermissionToTeam(userID, teamID, mmModel.PermissionViewTeam)
}
func (s *MattermostAuthLayer) GetCloudLimits() (*mmModel.ProductLimits, error) {
return s.pluginAPI.GetCloudLimits()
}
func (s *MattermostAuthLayer) IsUserGuest(userID string) (bool, error) {
user, err := s.GetUserByID(userID)
if err != nil {
return false, err
}
return strings.Contains(user.Roles, "guest"), nil
}
func (s *MattermostAuthLayer) GetUserTimezone(userID string) (string, error) {
query := s.getQueryBuilder().
Select("timezone").
From("Users").
Where(sq.Eq{"id": userID})
row := query.QueryRow()
var timezone mmModel.StringMap
err := row.Scan(&timezone)
if err != nil {
return "", err
}
return mmModel.GetPreferredTimezone(timezone), nil
}

View file

@ -675,6 +675,21 @@ func (mr *MockStoreMockRecorder) GetSystemSettings() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSystemSettings", reflect.TypeOf((*MockStore)(nil).GetSystemSettings))
}
// GetTeamBoardsInsights mocks base method.
func (m *MockStore) GetTeamBoardsInsights(arg0 []string, arg1 int64, arg2, arg3 int) (*model.BoardInsightsList, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTeamBoardsInsights", arg0, arg1, arg2, arg3)
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 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)
}
// GetUsedCardsCount mocks base method.
func (m *MockStore) GetUsedCardsCount() (int, error) {
m.ctrl.T.Helper()
@ -690,6 +705,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 string, arg1 []string, arg2 int64, arg3, arg4 int) (*model.BoardInsightsList, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserBoardsInsights", arg0, arg1, arg2, arg3, arg4)
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 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)
}
// GetUserByEmail mocks base method.
func (m *MockStore) GetUserByEmail(arg0 string) (*model.User, error) {
m.ctrl.T.Helper()
@ -735,6 +765,21 @@ func (mr *MockStoreMockRecorder) GetUserByUsername(arg0 interface{}) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByUsername", reflect.TypeOf((*MockStore)(nil).GetUserByUsername), arg0)
}
// 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)
}
// GetUserWorkspaces mocks base method.
func (m *MockStore) GetUserWorkspaces(arg0 string) ([]model.UserWorkspace, error) {
m.ctrl.T.Helper()
@ -750,6 +795,21 @@ func (mr *MockStoreMockRecorder) GetUserWorkspaces(arg0 interface{}) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserWorkspaces", reflect.TypeOf((*MockStore)(nil).GetUserWorkspaces), arg0)
}
// GetUserWorkspacesInTeam mocks base method.
func (m *MockStore) GetUserWorkspacesInTeam(arg0, arg1 string) ([]model.UserWorkspace, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserWorkspacesInTeam", arg0, arg1)
ret0, _ := ret[0].([]model.UserWorkspace)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserWorkspacesInTeam indicates an expected call of GetUserWorkspacesInTeam.
func (mr *MockStoreMockRecorder) GetUserWorkspacesInTeam(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserWorkspacesInTeam", reflect.TypeOf((*MockStore)(nil).GetUserWorkspacesInTeam), arg0, arg1)
}
// GetUsersByWorkspace mocks base method.
func (m *MockStore) GetUsersByWorkspace(arg0 string) ([]*model.User, error) {
m.ctrl.T.Helper()
@ -810,6 +870,20 @@ func (mr *MockStoreMockRecorder) GetWorkspaceTeam(arg0 interface{}) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceTeam", reflect.TypeOf((*MockStore)(nil).GetWorkspaceTeam), arg0)
}
// HasPermissionToTeam mocks base method.
func (m *MockStore) HasPermissionToTeam(arg0, arg1 string) bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "HasPermissionToTeam", arg0, arg1)
ret0, _ := ret[0].(bool)
return ret0
}
// HasPermissionToTeam indicates an expected call of HasPermissionToTeam.
func (mr *MockStoreMockRecorder) HasPermissionToTeam(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasPermissionToTeam", reflect.TypeOf((*MockStore)(nil).HasPermissionToTeam), arg0, arg1)
}
// HasWorkspaceAccess mocks base method.
func (m *MockStore) HasWorkspaceAccess(arg0, arg1 string) (bool, error) {
m.ctrl.T.Helper()
@ -867,6 +941,21 @@ func (mr *MockStoreMockRecorder) IsErrNotFound(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsErrNotFound", reflect.TypeOf((*MockStore)(nil).IsErrNotFound), arg0)
}
// IsUserGuest mocks base method.
func (m *MockStore) IsUserGuest(arg0 string) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsUserGuest", arg0)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// IsUserGuest indicates an expected call of IsUserGuest.
func (mr *MockStoreMockRecorder) IsUserGuest(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsUserGuest", reflect.TypeOf((*MockStore)(nil).IsUserGuest), arg0)
}
// PatchBlock mocks base method.
func (m *MockStore) PatchBlock(arg0 store.Container, arg1 string, arg2 *model.BlockPatch, arg3 string) error {
m.ctrl.T.Helper()

View file

@ -0,0 +1,177 @@
package sqlstore
import (
"database/sql"
"encoding/json"
"fmt"
"github.com/mattermost/focalboard/server/model"
sq "github.com/Masterminds/squirrel"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
func (s *SQLStore) getTeamBoardsInsights(db sq.BaseRunner, channelIDs []string,
since int64, offset int, limit int) (*model.BoardInsightsList, error) {
/*
Get top private, public boards, combine the list and filter the top 10. Note we can't limit 10 for subqueries.
*/
qb := s.getQueryBuilder(db)
publicBoards := qb.Select(`blocks.id, blocks.title, blocks.workspace_id,
count(blocks_history.id) as count, blocks_history.modified_by, blocks.created_by`).
From(s.tablePrefix + "blocks_history as blocks_history").
Join(s.tablePrefix + "blocks as blocks on blocks_history.root_id = blocks.id").
Where(sq.Gt{"blocks_history.update_at": since}).
Where(sq.Eq{"blocks_history.workspace_id": channelIDs}).
Where(sq.NotEq{"blocks_history.modified_by": "system"}).
Where(sq.Eq{"blocks.delete_at": 0}).
GroupBy("blocks.title, blocks.workspace_id, blocks.created_by, blocks.id, blocks_history.modified_by")
insightsQuery := qb.Select(fmt.Sprintf("id, title, workspace_id, sum(count) as activity_count, %s as active_users, created_by",
s.concatenationSelector("distinct modified_by", ","))).
FromSelect(publicBoards, "boards_and_blocks_history").
GroupBy("id, title, workspace_id, created_by").
OrderBy("activity_count desc").
Offset(uint64(offset)).
Limit(uint64(limit + 1))
rows, err := insightsQuery.Query()
if err != nil {
s.logger.Debug(`Team insights query ERROR`, mlog.Err(err))
return nil, err
}
defer s.CloseRows(rows)
boardInsights, err := boardsInsightsFromRows(rows)
if err != nil {
s.logger.Debug(`Team insights parsing error`, mlog.Err(err))
return nil, err
}
boardInsights, err = populateIcons(s, db, boardInsights)
if err != nil {
s.logger.Debug(`Team insights icon populate error`, mlog.Err(err))
return nil, err
}
boardInsightsPaginated := model.GetTopBoardInsightsListWithPagination(boardInsights, limit)
return boardInsightsPaginated, nil
}
func (s *SQLStore) getUserBoardsInsights(db sq.BaseRunner, userID string,
channelIDs []string, since int64, offset int, limit int) (*model.BoardInsightsList, error) {
/**
Get top 10 private, public boards, combine the list and filter the top 10
*/
qb := s.getQueryBuilder(db)
publicBoards := qb.Select(`blocks.id, blocks.title, blocks.workspace_id,
count(blocks_history.id) as count, blocks_history.modified_by, blocks.created_by`).
From(s.tablePrefix + "blocks_history as blocks_history").
Join(s.tablePrefix + "blocks as blocks on blocks_history.root_id = blocks.id").
Where(sq.Gt{"blocks_history.update_at": since}).
Where(sq.Eq{"blocks_history.workspace_id": channelIDs}).
Where(sq.NotEq{"blocks_history.modified_by": "system"}).
Where(sq.Eq{"blocks.delete_at": 0}).
GroupBy("blocks.title, blocks.workspace_id, blocks.created_by, blocks.id, blocks_history.modified_by")
userInsightsQuery := qb.Select("*").FromSelect(qb.Select(
fmt.Sprintf("id, title, workspace_id,sum(count) as activity_count, %s as active_users, created_by",
s.concatenationSelector("distinct modified_by", ","))).
FromSelect(publicBoards, "boards_and_blocks_history").
GroupBy("id, title, workspace_id, created_by").
OrderBy("activity_count desc"), "team_insights").
Where(sq.Or{
sq.Eq{
"created_by": userID,
},
// due to lack of position operator, we have to hardcode arguments, and placeholder here
sq.Expr(s.elementInColumn(5+len(channelIDs), "active_users")),
}).
Offset(uint64(offset)).
Limit(uint64(limit + 1))
userInsightsQueryStr, args, err := userInsightsQuery.ToSql()
if err != nil {
s.logger.Debug(`User insights query parsing ERROR`, mlog.Err(err))
return nil, err
}
// adding the 11th argument
args = append(args, userID)
rows, err := db.Query(userInsightsQueryStr, args...)
if err != nil {
s.logger.Debug(`User insights query ERROR`, mlog.Err(err))
return nil, err
}
defer s.CloseRows(rows)
boardInsights, err := boardsInsightsFromRows(rows)
if err != nil {
s.logger.Debug(`User insights rows parsing error`, mlog.Err(err))
return nil, err
}
boardInsights, err = populateIcons(s, db, boardInsights)
if err != nil {
s.logger.Debug(`User insights icon populate error`, mlog.Err(err))
return nil, err
}
boardInsightsPaginated := model.GetTopBoardInsightsListWithPagination(boardInsights, 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.WorkspaceID,
&boardInsight.ActivityCount,
&boardInsight.ActiveUsers,
&boardInsight.CreatedBy,
)
if err != nil {
return nil, err
}
boardsInsights = append(boardsInsights, &boardInsight)
}
return boardsInsights, nil
}
func populateIcons(s *SQLStore, db sq.BaseRunner, boardsInsights []*model.BoardInsight) ([]*model.BoardInsight, error) {
qb := s.getQueryBuilder(db)
for _, boardInsight := range boardsInsights {
// querying raw instead of calling store.GetBoardsFromSameID needs container, and this function has no context on channel ID
// performance wise, this is better since 1) it's querying for only fields 2) it's querying for only one row.
boardID := boardInsight.BoardID
iconQuery := qb.Select("COALESCE(fields, '{}')").From(s.tablePrefix + "blocks").Where(sq.Eq{"id": boardID})
iconQueryString, args, err := iconQuery.ToSql()
if err != nil {
s.logger.Error(`Query parsing error while getting icons`, mlog.Err(err))
return nil, err
}
row := s.db.QueryRow(iconQueryString, args...)
var fieldsJSON string
var fields map[string]interface{}
err = row.Scan(
&fieldsJSON,
)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(fieldsJSON), &fields)
if err != nil {
s.logger.Error(`ERROR unmarshalling populateIcons fields`, mlog.Err(err))
return nil, err
}
boardInsight.Icon = fields["icon"].(string)
}
return boardsInsights, nil
}

View file

@ -20,6 +20,7 @@ import (
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/services/store"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
@ -252,11 +253,21 @@ func (s *SQLStore) GetSystemSettings() (map[string]string, error) {
}
func (s *SQLStore) GetTeamBoardsInsights(channelIDs []string, since int64, offset int, limit int) (*model.BoardInsightsList, error) {
return s.getTeamBoardsInsights(s.db, channelIDs, since, offset, limit)
}
func (s *SQLStore) GetUsedCardsCount() (int, error) {
return s.getUsedCardsCount(s.db)
}
func (s *SQLStore) GetUserBoardsInsights(userID string, channelIDs []string, since int64, offset int, limit int) (*model.BoardInsightsList, error) {
return s.getUserBoardsInsights(s.db, userID, channelIDs, since, offset, limit)
}
func (s *SQLStore) GetUserByEmail(email string) (*model.User, error) {
return s.getUserByEmail(s.db, email)
@ -272,11 +283,20 @@ func (s *SQLStore) GetUserByUsername(username string) (*model.User, error) {
}
func (s *SQLStore) GetUserTimezone(userID string) (string, error) {
return s.getUserTimezone(s.db, userID)
}
func (s *SQLStore) GetUserWorkspaces(userID string) ([]model.UserWorkspace, error) {
return s.getUserWorkspaces(s.db, userID)
}
func (s *SQLStore) GetUserWorkspacesInTeam(userID string, teamID string) ([]model.UserWorkspace, error) {
return s.getUserWorkspacesInTeam(s.db, userID, teamID)
}
func (s *SQLStore) GetUsersByWorkspace(workspaceID string) ([]*model.User, error) {
return s.getUsersByWorkspace(s.db, workspaceID)
@ -297,6 +317,11 @@ func (s *SQLStore) GetWorkspaceTeam(workspaceID string) (*mmModel.Team, error) {
}
func (s *SQLStore) HasPermissionToTeam(userID string, teamID string) bool {
return s.hasPermissionToTeam(s.db, userID, teamID)
}
func (s *SQLStore) HasWorkspaceAccess(userID string, workspaceID string) (bool, error) {
return s.hasWorkspaceAccess(s.db, userID, workspaceID)
@ -350,6 +375,11 @@ func (s *SQLStore) InsertBlocks(c store.Container, blocks []model.Block, userID
}
func (s *SQLStore) IsUserGuest(userID string) (bool, error) {
return s.isUserGuest(s.db, userID)
}
func (s *SQLStore) PatchBlock(c store.Container, blockID string, blockPatch *model.BlockPatch, userID string) error {
if s.dbType == sqliteDBType {
return s.patchBlock(s.db, c, blockID, blockPatch, userID)
@ -413,13 +443,14 @@ func (s *SQLStore) RemoveDefaultTemplates(blocks []model.Block) error {
}
func (s *SQLStore) SendMessage(message string, postType string, receipts []string) error {
return s.sendMessage(s.db, message, postType, receipts)
}
//nolint:typecheck
func (s *SQLStore) SaveFileInfo(fileInfo *mmModel.FileInfo) error {
return s.saveFileInfo(s.db, fileInfo)
}
func (s *SQLStore) SendMessage(message string, postType string, receipts []string) error {
return s.sendMessage(s.db, message, postType, receipts)
}
func (s *SQLStore) SetSystemSetting(key string, value string) error {

View file

@ -2,6 +2,7 @@ package sqlstore
import (
"database/sql"
"fmt"
sq "github.com/Masterminds/squirrel"
@ -99,6 +100,39 @@ func (s *SQLStore) getLicense(db sq.BaseRunner) *mmModel.License {
return nil
}
func (s *SQLStore) concatenationSelector(field string, delimiter string) string {
if s.dbType == sqliteDBType {
return fmt.Sprintf("group_concat(%s)", field)
}
if s.dbType == postgresDBType {
return fmt.Sprintf("string_agg(%s, '%s')", field, delimiter)
}
if s.dbType == mysqlDBType {
return fmt.Sprintf("GROUP_CONCAT(%s SEPARATOR '%s')", field, delimiter)
}
return ""
}
func (s *SQLStore) elementInColumn(parameterCount int, column string) string {
if s.dbType == sqliteDBType || s.dbType == mysqlDBType {
return fmt.Sprintf("instr(%s, %s) > 0", column, s.parameterPlaceholder(parameterCount))
}
if s.dbType == postgresDBType {
return fmt.Sprintf("position(%s in %s) > 0", s.parameterPlaceholder(parameterCount), column)
}
return ""
}
func (s *SQLStore) parameterPlaceholder(count int) string {
if s.dbType == postgresDBType || s.dbType == sqliteDBType {
return fmt.Sprintf("$%v", count)
}
if s.dbType == mysqlDBType {
return "?"
}
return ""
}
func (s *SQLStore) getCloudLimits(db sq.BaseRunner) (*mmModel.ProductLimits, error) {
return nil, nil
}

View file

@ -7,6 +7,7 @@ import (
"testing"
"github.com/mattermost/focalboard/server/services/store/storetests"
"github.com/stretchr/testify/require"
)
func TestBlocksStore(t *testing.T) {
@ -19,4 +20,53 @@ func TestBlocksStore(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("CloudStore", func(t *testing.T) { storetests.StoreTestCloudStore(t, SetupTests) })
t.Run("BoardsInsightsStore", func(t *testing.T) { storetests.StoreTestBoardsInsightsStore(t, SetupTests) })
}
func TestConcatenationSelector(t *testing.T) {
store, tearDown := SetupTests(t)
sqlStore := store.(*SQLStore)
defer tearDown()
concatenationString := sqlStore.concatenationSelector("a", ",")
switch sqlStore.dbType {
case sqliteDBType:
require.Equal(t, concatenationString, "group_concat(a)")
case mysqlDBType:
require.Equal(t, concatenationString, "GROUP_CONCAT(a SEPARATOR ',')")
case 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(1, "test_column")
switch sqlStore.dbType {
case sqliteDBType:
require.Equal(t, inLiteral, "instr(test_column, $1) > 0")
case mysqlDBType:
require.Equal(t, inLiteral, "instr(test_column, ?) > 0")
case postgresDBType:
require.Equal(t, inLiteral, "position($1 in test_column) > 0")
}
}
func TestParameterPlaceholder(t *testing.T) {
store, tearDown := SetupTests(t)
sqlStore := store.(*SQLStore)
defer tearDown()
parameterPlaceholder := sqlStore.parameterPlaceholder(2)
switch sqlStore.dbType {
case sqliteDBType:
require.Equal(t, parameterPlaceholder, "$2")
case postgresDBType:
require.Equal(t, parameterPlaceholder, "$2")
case mysqlDBType:
require.Equal(t, parameterPlaceholder, "?")
}
}

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log"
"strings"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/utils"
@ -256,6 +257,22 @@ func (s *SQLStore) patchUserProps(db sq.BaseRunner, userID string, patch model.U
return s.updateUser(db, user)
}
func (s *SQLStore) hasPermissionToTeam(db sq.BaseRunner, userID string, teamID string) bool {
return false
}
func (s *SQLStore) sendMessage(db sq.BaseRunner, message, postType string, receipts []string) error {
return errUnsupportedOperation
}
func (s *SQLStore) isUserGuest(_ sq.BaseRunner, userID string) (bool, error) {
user, err := s.GetUserByID(userID)
if err != nil {
return false, err
}
return strings.Contains(user.Roles, "guest"), nil
}
func (s *SQLStore) getUserTimezone(_ sq.BaseRunner, userID string) (string, error) {
return "", errUnsupportedOperation
}

View file

@ -157,6 +157,10 @@ func (s *SQLStore) getUserWorkspaces(_ sq.BaseRunner, _ string) ([]model.UserWor
return nil, fmt.Errorf("GetUserWorkspaces %w", errUnsupportedOperation)
}
func (s *SQLStore) getUserWorkspacesInTeam(_ sq.BaseRunner, _ string, _ string) ([]model.UserWorkspace, error) {
return nil, fmt.Errorf("GetUserWorkspacesInTeam %w", errUnsupportedOperation)
}
func (s *SQLStore) createPrivateWorkspace(_ sq.BaseRunner, _ string) (string, error) {
// for personal server we always have only
// a single workspace, with id "0".

View file

@ -68,6 +68,7 @@ type Store interface {
UpdateUserPasswordByID(userID, password string) error
GetUsersByWorkspace(workspaceID string) ([]*model.User, error)
PatchUserProps(userID string, patch model.UserPropPatch) error
HasPermissionToTeam(userID string, teamID string) bool
GetActiveUserCount(updatedSecondsAgo int64) (int, error)
GetSession(token string, expireTime int64) (*model.Session, error)
@ -86,6 +87,7 @@ type Store interface {
HasWorkspaceAccess(userID string, workspaceID string) (bool, error)
GetWorkspaceCount() (int64, error)
GetUserWorkspaces(userID string) ([]model.UserWorkspace, error)
GetUserWorkspacesInTeam(userID string, teamID string) ([]model.UserWorkspace, error)
CreatePrivateWorkspace(userID string) (string, error)
CreateSubscription(c Container, sub *model.Subscription) (*model.Subscription, error)
@ -117,7 +119,13 @@ type Store interface {
GetFileInfo(id string) (*mmModel.FileInfo, error)
SaveFileInfo(fileInfo *mmModel.FileInfo) error
GetLicense() *mmModel.License
// Insights
GetTeamBoardsInsights(channelIDs []string, since int64, offset int, limit int) (*model.BoardInsightsList, error)
GetUserBoardsInsights(userID string, channelIDs []string, since int64, offset int, limit int) (*model.BoardInsightsList, error)
IsUserGuest(userID string) (bool, error)
GetCloudLimits() (*mmModel.ProductLimits, error)
GetUserTimezone(userID string) (string, error)
}
// ErrNotFound is an error type that can be returned by store APIs when a query unexpectedly fetches no records.

View file

@ -0,0 +1,129 @@
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"
testInsightsUserID2 = "user-id-2"
testInsightsChannelID1 = "channel-id-1"
testInsightsChannelID2 = "channel-id-2"
)
func StoreTestBoardsInsightsStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) {
container1 := store.Container{
WorkspaceID: testInsightsChannelID1,
}
container2 := store.Container{
WorkspaceID: testInsightsChannelID2,
}
t.Run("GetBoardsInsights", func(t *testing.T) {
store, tearDown := setup(t)
defer tearDown()
getBoardsInsightsTest(t, store, container1, container2)
})
}
func getBoardsInsightsTest(t *testing.T, store store.Store, container1 store.Container, container2 store.Container) {
// creating sample data
userID := testUserID
block1 := model.Block{
ID: "insights-id-1",
RootID: "insights-id-1",
ModifiedBy: userID,
WorkspaceID: testInsightsChannelID1,
Fields: map[string]interface{}{"icon": "💬"},
}
block2 := model.Block{
ID: "insights-id-2",
RootID: "insights-id-2",
ModifiedBy: userID,
WorkspaceID: testInsightsChannelID2,
Fields: map[string]interface{}{"icon": "💬"},
}
blockMember1 := model.Block{
ID: "insights-id-3",
RootID: "insights-id-1",
Title: "Old Title 1",
WorkspaceID: testInsightsChannelID1,
}
blockMember2 := model.Block{
ID: "insights-id-4",
RootID: "insights-id-1",
Title: "Old Title 2",
WorkspaceID: testInsightsChannelID1,
}
blockMember3 := model.Block{
ID: "insights-id-5",
RootID: "insights-id-2",
Title: "Old Title 1",
WorkspaceID: testInsightsChannelID2,
}
blockMember4 := model.Block{
ID: "insights-id-6",
RootID: "insights-id-2",
Title: "Old Title 2",
WorkspaceID: testInsightsChannelID2,
}
// container 1
newBlocks1 := []model.Block{blockMember1, blockMember2}
err := store.InsertBlock(container1, &block1, testInsightsUserID1)
require.NoError(t, err)
err = store.InsertBlocks(container1, newBlocks1, testInsightsUserID1)
require.NoError(t, err)
// container 2
newBlocks2 := []model.Block{blockMember3, blockMember4}
err = store.InsertBlock(container2, &block2, testInsightsUserID2)
require.NoError(t, err)
err = store.InsertBlocks(container2, newBlocks2, testInsightsUserID2)
require.NoError(t, err)
require.NoError(t, err)
blocks1, err := store.GetAllBlocks(container1)
require.NoError(t, err)
blocks2, err := store.GetAllBlocks(container2)
require.NoError(t, err)
t.Run("team insights", func(t *testing.T) {
topTeamBoards, err := store.GetTeamBoardsInsights([]string{testInsightsChannelID1, testInsightsChannelID2},
0, 0, 10)
require.NoError(t, err)
require.Len(t, topTeamBoards.Items, 2)
require.NoError(t, err)
require.Len(t, blocks1, 3)
require.Len(t, blocks2, 3)
// validate board insight content
require.Equal(t, topTeamBoards.Items[0].ActivityCount, 3)
require.Equal(t, topTeamBoards.Items[1].ActivityCount, 3)
require.Equal(t, topTeamBoards.Items[0].BoardID, block1.ID)
require.Equal(t, topTeamBoards.Items[1].BoardID, block2.ID)
})
t.Run("user insights", func(t *testing.T) {
topUser1Boards, err := store.GetUserBoardsInsights(testInsightsUserID1,
[]string{testInsightsChannelID1, testInsightsChannelID2}, 0, 0, 10)
require.NoError(t, err)
require.Len(t, topUser1Boards.Items, 1)
require.Equal(t, topUser1Boards.Items[0].Icon, "💬")
require.Equal(t, topUser1Boards.Items[0].BoardID, block1.ID)
topUser2Boards, err := store.GetUserBoardsInsights(testInsightsUserID2,
[]string{testInsightsChannelID1, testInsightsChannelID2}, 0, 0, 10)
require.NoError(t, err)
require.Len(t, topUser2Boards.Items, 1)
require.Equal(t, topUser2Boards.Items[0].Icon, "💬")
require.Equal(t, topUser2Boards.Items[0].BoardID, block2.ID)
})
}

View file

@ -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[];
}

View file

@ -11,6 +11,7 @@ import {UserSettings} from './userSettings'
import {Subscription} from './wsclient'
import {PrepareOnboardingResponse} from './onboardingTour'
import {BoardsCloudLimits} from './boardsCloudLimits'
import {TopBoardResponse} from './insights'
//
// OctoClient is the client interface to the server APIs
@ -536,6 +537,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<TopBoardResponse | undefined> {
const path = `/api/v1/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<TopBoardResponse | undefined> {
const path = `/api/v1/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()